Skip to content
IzzyBlazor 0.1.0-alpha.17

Enter all results ↑ ↓ move Esc close

AppShell

Header, sidebar, main area, aside and footer, arranged by CSS.

An interactive page: C# runs over a Blazor Server connection.

Usage

MainLayout.razor
@inherits LayoutComponentBase

<AppShell>
    <AppHeader>
        <SidebarToggle />
        <AppBrand Href="/" Icon="Lucide.Zap" Label="Acme" />
    </AppHeader>
    <AppSidebar>
        <NavMenu>
            <NavItem Href="/" Icon="Lucide.House">Home</NavItem>
            <NavItem Href="/projects" Icon="Lucide.Folder">Projects</NavItem>
        </NavMenu>
    </AppSidebar>
    <AppMain>@Body</AppMain>
</AppShell>

An app shell goes in your layout, around @Body. The parts you put in it arrange themselves with CSS alone, so a static page arrives laid out and works with JavaScript off. It starts with a "Skip to content" link to AppMain, whose id is ib-main; set MainId on the shell to change it.

The parts

Use AppHeader, AppSidebar, AppMain, AppAside and AppFooter in any order: each takes its own place, and a part you leave out leaves no gap.

Preview width

Preview

Code

Parts.razor
@inject NavigationManager Navigation

@* The five parts in their places: the header across the top, the sidebar beside the main area,
   the aside on the other side and the footer at the bottom. Below 768px the sidebar becomes a
   drawer behind the menu button, and the aside hides (its Breakpoint, Md here; Lg by default). *@
<AppShell>
    <AppHeader>
        <SidebarToggle />
        <AppBrand Href="@To(null)" Icon="Lucide.Zap" Label="Acme" />
        <Box Grow />
        <IconButton Icon="Lucide.Bell" Label="Notifications" Variant="ButtonVariant.Ghost" Accent="Accent.Neutral" />
    </AppHeader>

    <AppSidebar>
        <NavMenu>
            <NavItem Href="@To(null)" Icon="Lucide.Folder">Projects</NavItem>
            <NavItem Href='@To("team")' Icon="Lucide.Users">Team</NavItem>
            <NavItem Href='@To("settings")' Icon="Lucide.Settings">Settings</NavItem>
        </NavMenu>
    </AppSidebar>

    <AppMain>
        <Stack Gap="4" Padding="new() { Base = 4, Md = 8 }">
            <Heading Level="1" Size="TextSize.Xl2">Projects</Heading>
            <TextBlock Color="Color.Muted">The main area holds the page. It scrolls with the window, while the header stays at the top and the sidebar's links stay in view.</TextBlock>
            <Card Title="Website" Description="Deployed 12 minutes ago from main." />
            <Card Title="Course app" Description="Build failed on feature/quizzes." />
        </Stack>
    </AppMain>

    <AppAside Breakpoint="Breakpoint.Md" aria-label="Details">
        <Stack Gap="2">
            <TextBlock Size="TextSize.Sm" Weight="FontWeight.Semibold">Details</TextBlock>
            <TextBlock Size="TextSize.Sm" Color="Color.Muted">The aside sits on the side opposite the sidebar, and stays in view while the page scrolls.</TextBlock>
        </Stack>
    </AppAside>

    <AppFooter>
        <TextBlock Size="TextSize.Sm" Color="Color.Muted">© 2026 Acme</TextBlock>
    </AppFooter>
</AppShell>

@code {
    // Links within this example: ?view= in its own address, so they stay here.
    private string To(string? page) => Navigation.GetUriWithQueryParameter("view", page);
}

At phone width the sidebar becomes a drawer behind the menu button (SidebarToggle) and the aside hides, each below its own Breakpoint: 768px for both here. The AppSidebar page covers the drawer, an icon rail and a panel under the header.

Variants

Use Variant to layer the parts: Tinted (the default) tints the sidebar, Inset raises the main area as a panel in a tinted frame, Flat makes one surface with lines between the parts, and Canvas tints the main area so cards stand out.

Preview width

Preview

Code

Variants.razor
@inject NavigationManager Navigation

@* Variant layers the parts: Tinted tints the sidebar, Inset raises the main area as a panel in a
   tinted frame, Flat is one surface with lines between the parts, and Canvas tints the main area
   so cards stand out. The links above the cards set ?variant= in this example's address. *@
<AppShell Variant="Variant">
    <AppHeader>
        <SidebarToggle />
        <AppBrand Href="@View(null)" Icon="Lucide.Zap" Label="Acme" />
    </AppHeader>

    <AppSidebar>
        <NavMenu>
            <NavItem Href="@View(null)" Icon="Lucide.LayoutDashboard">Overview</NavItem>
            <NavItem Href='@View("reports")' Icon="Lucide.ChartColumn">Reports</NavItem>
            <NavItem Href='@View("settings")' Icon="Lucide.Settings">Settings</NavItem>
        </NavMenu>
    </AppSidebar>

    <AppMain>
        <Stack Gap="5" Padding="new() { Base = 4, Md = 8 }">
            <Heading Level="1" Size="TextSize.Xl2">@Variant</Heading>
            <nav aria-label="Variant">
                <NavMenu Orientation="Orientation.Horizontal">
                    <NavItem Href="@To(null)">Tinted</NavItem>
                    <NavItem Href='@To("inset")'>Inset</NavItem>
                    <NavItem Href='@To("flat")'>Flat</NavItem>
                    <NavItem Href='@To("canvas")'>Canvas</NavItem>
                </NavMenu>
            </nav>
            <Grid MinChildWidth="Length.Rem(12)" Gap="4">
                <Card Title="Visitors" Description="12,480 this week" />
                <Card Title="Sign-ups" Description="342 this week" />
            </Grid>
        </Stack>
    </AppMain>
</AppShell>

@code {
    [SupplyParameterFromQuery(Name = "variant")]
    private string? Picked { get; set; }

    private ShellVariant Variant =>
        Enum.TryParse<ShellVariant>(Picked, ignoreCase: true, out var variant) && Enum.IsDefined(variant) ? variant : ShellVariant.Tinted;

    // Links within this example, in its own address so they stay here: ?variant= for the look,
    // ?view= for the sidebar's pages.
    private string To(string? variant) => Navigation.GetUriWithQueryParameter("variant", variant);

    private string View(string? page) => Navigation.GetUriWithQueryParameter("view", page);
}

Put a Canvas page's content in Cards: fills placed straight on the tint read as holes. Use Dividers="false" to drop the lines between the parts when their colors already tell them apart.

A reading column

Use ContentWidth to center the content of the header, the main area and the footer on one column, such as Length.Rem(42) for articles, while the bars and their lines stay full width.

Preview width

Preview

Code

Reading.razor
@using System.ComponentModel.DataAnnotations
@inject NavigationManager Navigation

@* A reading site with no sidebar. ContentWidth puts the header, the article and the footer on one
   centered column, so their edges line up, while the bars and their lines stay full width. *@
<AppShell Variant="ShellVariant.Flat" ContentWidth="Length.Rem(42)">
    <AppHeader>
        <AppBrand Href="@To(null)" Icon="Lucide.Zap" Label="Acme Journal" />
        <Box Grow />
        <SiteNav Label="Sections" Breakpoint="Breakpoint.Sm">
            <NavItem Href='@To("articles")'>Articles</NavItem>
            <NavItem Href='@To("guides")'>Guides</NavItem>
        </SiteNav>
        <IconButton Icon="Lucide.Search" Label="Search" Variant="ButtonVariant.Ghost" Accent="Accent.Neutral" />
    </AppHeader>

    <AppMain>
        <Stack Gap="5" PaddingY="new() { Base = 8, Md = 12 }">
            <TextBlock Size="TextSize.Xs" Weight="FontWeight.Semibold" Color="Color.Muted">ENGINEERING</TextBlock>
            <Heading Level="1" Size="TextSize.Xl3">How we review a rule before it goes live</Heading>
            <Row Gap="3" Align="Align.Center">
                <Avatar Name="Grace Hopper" Size="AvatarSize.Sm" />
                <TextBlock Size="TextSize.Sm" Color="Color.Muted">Grace Hopper · Oct 9, 2026 · 6 min read</TextBlock>
            </Row>
            <TextBlock>Every rule we ship decides real money. A wrong condition can hold a thousand honest refunds before anyone notices, so no rule goes live on one person's say-so.</TextBlock>
            <Heading Level="2" Size="TextSize.Xl">Start with a dry run</Heading>
            <TextBlock>A dry run replays last week's decisions through the new rule and lists what would have changed. Reviewers read that list before they read the rule: outcomes are what customers feel.</TextBlock>
            <Alert Title="One reviewer is not enough">Rules that change more than 1% of last week's outcomes need two approvals.</Alert>
        </Stack>
        @* A full-bleed band: the color spans the main area, the content stays on the column. *@
        <Section Bg="Color.FillSubtle">
            <Stack Gap="3">
                <Heading Level="2" Size="TextSize.Xl">Get the next article by email</Heading>
                <Form Model="Model" FormName="reading-newsletter" OnValidSubmit="() => _subscribed = true">
                    <TextField @bind-Value="Model!.Email" Type="TextFieldType.Email" Placeholder="you@example.com" HideLabel>
                        <EndAddon><Button Submit>Subscribe</Button></EndAddon>
                    </TextField>
                </Form>
                @if (_subscribed)
                {
                    <TextBlock Size="TextSize.Sm" Color="Color.Muted">Thanks, @Model!.Email is on the list.</TextBlock>
                }
            </Stack>
        </Section>
    </AppMain>

    <AppFooter>
        <Row Gap="4" Align="Align.Center" Wrap>
            <TextBlock Size="TextSize.Sm" Color="Color.Muted" Grow>© 2026 Acme</TextBlock>
            <TextBlock As="span" Size="TextSize.Sm"><Anchor Href='@To("rss")'>RSS</Anchor></TextBlock>
            <TextBlock As="span" Size="TextSize.Sm"><Anchor Href='@To("privacy")'>Privacy</Anchor></TextBlock>
        </Row>
    </AppFooter>
</AppShell>

@code {
    // Links within this example: ?view= in its own address, so they stay here.
    private string To(string? page) => Navigation.GetUriWithQueryParameter("view", page);

    [SupplyParameterFromForm(FormName = "reading-newsletter")]
    private Newsletter? Model { get; set; }

    private bool _subscribed;

    protected override void OnInitialized() => Model ??= new();

    public sealed class Newsletter
    {
        [Required(ErrorMessage = "Enter your email"), EmailAddress(ErrorMessage = "Enter an email like name@example.com"), Display(Name = "Email")]
        public string? Email { get; set; }
    }
}

Live: Subscribe checks the address and runs C# without a post.

A part's own ContentWidth wins over the shell's, for a wider header over a narrow article, and a Section in the main area still spans it, for a band of color from edge to edge.

Scrolling like a desktop app

Use ScrollMode="ShellScroll.Main" to fill the window and scroll only the main area, so the header, the sidebar and the footer never move.

Preview width

Preview

Code

Inbox.razor
@inject NavigationManager Navigation

@* A mail client that behaves like a desktop app: ScrollMode.Main fills the window and scrolls only
   the message list, so the header, the folders and the footer never move. *@
<AppShell ScrollMode="ShellScroll.Main">
    <AppHeader>
        <SidebarToggle />
        <AppBrand Href="@To(null)" Icon="Lucide.Mail" Label="Acme Mail" />
        <Box Grow />
        <Button StartIcon="Lucide.Send" Size="ControlSize.Sm">Compose</Button>
        <Avatar Name="Ada Lovelace" />
    </AppHeader>

    <AppSidebar Width="Length.Rem(14)">
        <NavMenu>
            <NavItem Href="@To(null)" Icon="Lucide.Inbox" Badge="4" BadgeAccent="Accent.Primary">Inbox</NavItem>
            <NavItem Href='@To("starred")' Icon="Lucide.Star">Starred</NavItem>
            <NavItem Href='@To("sent")' Icon="Lucide.Send">Sent</NavItem>
            <NavItem Href='@To("archive")' Icon="Lucide.Archive">Archive</NavItem>
            <NavItem Href='@To("trash")' Icon="Lucide.Trash">Trash</NavItem>
        </NavMenu>
    </AppSidebar>

    <AppMain>
        <Stack Gap="0">
            @foreach (var (sender, subject, time, unread) in Messages)
            {
                <Row Gap="3" Align="Align.Center" PaddingX="5" PaddingY="3" BorderBottom="1">
                    <Avatar Name="@sender" Size="AvatarSize.Sm" />
                    <Stack Gap="0" Grow MinWidth="Length.Px(0)">
                        <TextBlock Size="TextSize.Sm" Weight="@(unread ? FontWeight.Semibold : FontWeight.Normal)">@sender</TextBlock>
                        <TextBlock Size="TextSize.Sm" Color="Color.Muted" Truncate>@subject</TextBlock>
                    </Stack>
                    <TextBlock Size="TextSize.Xs" Color="Color.Muted">@time</TextBlock>
                </Row>
            }
        </Stack>
    </AppMain>

    <AppFooter>
        <TextBlock Size="TextSize.Sm" Color="Color.Muted">3.2 GB of 15 GB used · Synced 2 minutes ago</TextBlock>
    </AppFooter>
</AppShell>

@code {
    // Links within this example: ?view= in its own address, so they stay here.
    private string To(string? page) => Navigation.GetUriWithQueryParameter("view", page);
}

With the default, ShellScroll.Page, the window scrolls as on any web page: the header stays at the top and the sidebar's and aside's content stays in view beside the page.

An aside

Use AppAside for a column on the side opposite the sidebar, such as an outline with OnThisPage, which lists the main area's headings from the server. It shows from Breakpoint up, 1024px by default, and is Width wide, 15rem.

razor
<AppShell>
    ...
    <AppMain>@Body</AppMain>
    <AppAside>
        <OnThisPage />
    </AppAside>
</AppShell>

An aside has no name of its own, so give one that holds something other than an outline an aria-label, as the parts example does ("Details"). The docs demo has an outline beside an article.

Bars and colors

Use Bg and ColorScheme on a part for its own background or scheme, and Position on AppHeader and AppFooter for whether the bar stays in view (the header does by default; the footer doesn't).

razor
<AppShell>
    <AppHeader Bg="Color.FillSubtle">...</AppHeader>
    <AppSidebar ColorScheme="ColorScheme.Dark">...</AppSidebar>
    <AppMain>@Body</AppMain>
    <AppFooter Position="BarPosition.Sticky">...</AppFooter>
</AppShell>

Text on a Bg takes the color's matching text color when it has one, and everything inside a part with a ColorScheme, its menus included, follows that scheme. The full-height sidebar demo has a dark sidebar in a light app.

The brand

Use AppBrand for the app's name in the header, with an Icon or a Logo before it and Href to link home; HideLabel shows only a logo that already spells the name.

razor
<AppHeader>
    <SidebarToggle />
    <AppBrand Href="/" Label="Acme" HideLabel>
        <Logo><img src="logo.svg" alt="" width="96" height="24" /></Logo>
    </AppBrand>
</AppHeader>

The name stays on one line: on a narrow screen it's cut short with an ellipsis rather than pushing the header's buttons off. A sidebar with no SidebarHeader shows the brand in its drawer's top row too.

Parts

Written inside AppShell. The API page lists their parameters.

  • AppHeader: the top bar of an AppShell: brand, SidebarToggle, actions.
  • AppBrand: the app or site name in an AppHeader (or a sidebar's header), with an icon or logo before it, usually linking home.
  • AppMain: the main content of an AppShell, usually @Body.
  • AppAside: an optional side column of an AppShell, such as a table of contents, on the side opposite the sidebar.
  • AppFooter: the bottom bar of an AppShell.

Mode support

What works in each mode, AppShell
FeatureOffStaticLive
EverythingYesYesYes

Off: JavaScript disabled. Static: a static SSR page with IzzyBlazor's script. Live: an interactive page (Server, WebAssembly or Auto).

Accessibility

  • It starts with a "Skip to content" link (IzzyText.SkipToContent), shown when focused.
  • The parts are landmarks: AppHeader a header, AppMain a main, AppAside an aside, AppFooter a footer, and AppSidebar a nav.
  • The skip link moves focus to the main area (MainId), with JavaScript off too.
  • With ShellScroll.Main the main area is also a tab stop, so it can be scrolled from the keyboard when nothing in it takes focus.
  • AppBrand is named by its Label, which screen readers still hear with HideLabel; a Logo is hidden from them.
  • AppAside has no name of its own: add an aria-label, such as "On this page", when it helps.

Parameters

AppShell parameters
PropTypeDefault
ChildContentThe parts: AppHeader, AppSidebar, AppMain, AppAside, AppFooter.RenderFragment?
ScrollModeWhat scrolls: the whole page (ShellScroll.Page, the default) or only the main area inside a window-filling frame (ShellScroll.Main).ShellScrollShellScroll.Page
VariantHow the parts are layered: ShellVariant.Tinted (the default) tints the sidebar, ShellVariant.Inset raises the main area as a panel in a tinted frame, ShellVariant.Flat is all surface, ShellVariant.Canvas tints the main area.ShellVariantShellVariant.Tinted
DividersLines between the parts.booltrue
ContentWidthCenters the content of the header, main area and footer at this width, such as Length.Rem(48) for a reading site, so their edges line up; the bars and their lines stay full width.Length?
MainIdThe main area's id, which the skip link targets.string"ib-main"

See also