The frame an app runs in: a collapsible floating rail, a sticky header, the page and a bottom tab bar.

Layout

AppShell takes three slots and the page.

  • sidebar: the rail, a Sidebar. It floats 16px inside the top, start and bottom of the screen. From 1024px up, the workspace is offset by its width.
  • header: the page header, such as a Page header, drawn as the first part of <main> and sticky to the top. The shell only makes it sticky; its look is the header's own.
  • bottomBar: a bottom tab bar for phones.
  • children: the page, rendered inside the shell's one <main>.

The examples on this page sit in a frame, so the rail floats inside it. In an app it floats over the window.

Example

Skip to content

Home

Spending is on track this month.

Follows the page theme and corners
import { AppShell } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { ShellHeader, ShellNav, ShellPage } from './shell-nav';

export default function AppShellBasic(): ReactElement {
  return (
    <div className="stage-shell">
      <AppShell sidebar={<ShellNav label="Main" />} header={<ShellHeader />}>
        <ShellPage />
      </AppShell>
    </div>
  );
}

Collapsed

defaultSidebarCollapsed starts the rail as a 72px column of icons, with the page 104px in. The collapse button on the rail's edge toggles it, and the shell keeps the choice until the page reloads. Control it with isSidebarCollapsed and onSidebarCollapsedChange.

While collapsed, the root carries data-sidebar="collapsed", and CSS does the rest. The attribute narrows the rail from any ancestor.

Under 1440px an open SidePanel folds the rail to its icons and gives it back as it closes, so the content beside the panel keeps its room. The fold is not the reader's choice: onSidebarCollapsedChange is not called and a cookie that keeps the choice never holds it. useAppShell().isSidebarCollapsed reports the rail as drawn, folded or not. Expanding the rail over an open panel keeps it expanded until the panel closes. The fold needs no script: a page loaded with a panel open, from a link to a row, draws the rail folded from its first paint.

Example

Skip to content

Home

Spending is on track this month.

Follows the page theme and corners
import { AppShell } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { ShellHeader, ShellNav, ShellPage } from './shell-nav';

export default function AppShellCollapsed(): ReactElement {
  return (
    <div className="stage-shell">
      <AppShell
        defaultSidebarCollapsed
        sidebar={<ShellNav label="Main, collapsed" />}
        header={<ShellHeader />}
      >
        <ShellPage />
      </AppShell>
    </div>
  );
}

The first paint must be the right width without measuring anything. In the Next.js App Router, read the choice from a cookie in a server layout and pass it as the default. A thin client wrapper writes the cookie when it changes:

// app/(app)/layout.tsx, a server component
import { cookies } from 'next/headers';
import type { ReactNode } from 'react';
import { Shell } from './shell';
// The app's own rail and header: a Sidebar and a header holding a SidebarTrigger.
import { AppHeader, AppNav } from './nav';

export default async function Layout({ children }: { children: ReactNode }) {
  const collapsed = (await cookies()).get('sidebar')?.value === 'collapsed';
  return (
    <Shell defaultSidebarCollapsed={collapsed} sidebar={<AppNav />} header={<AppHeader />}>
      {children}
    </Shell>
  );
}
// app/(app)/shell.tsx
'use client';

import { AppShell, type AppShellProps } from '@snapshot-ui/react';

export function Shell(props: Omit<AppShellProps, 'onSidebarCollapsedChange'>) {
  return (
    <AppShell
      {...props}
      onSidebarCollapsedChange={(collapsed) => {
        document.cookie = `sidebar=${collapsed ? 'collapsed' : 'expanded'}; path=/; max-age=31536000`;
      }}
    />
  );
}

The shell stays uncontrolled. A full page load reads the cookie again, and in between the shell's own state is right.

A static site has no cookie. Instead, a script that runs before the first paint sets data-sidebar="collapsed" on <html> from local storage, and the app feeds isSidebarCollapsed from useSyncExternalStore over that attribute. The same CSS rule serves both, because the attribute works on any ancestor. The shell writes collapsed or nothing, never expanded, so it never resets an attribute set higher up.

The first Tab lands on a skip link, hidden until focused and then shown as an ink pill over the rail's corner. Enter moves focus to <main>, past the rail. The header is the first part of <main>, so a screen reader goes on from the page's title, and the page's h1 is inside the main landmark.

  • skipLinkLabel replaces its text, "Skip to content".
  • mainId sets the id of <main>. Without it, the id comes from useId.
  • useAppShell().mainId gives the id to anything else that links to it.

The sticky header

The header sticks to the top as the page scrolls. Once the page has loaded, the shell measures it and writes its height as --page-sticky-top on <main>, under which a Data table's header row and a Side panel stick. It follows the header as it grows or wraps, and goes when it does. Nothing is written on the root element, and the page's scroll is never padded: a control that focus lands on under the stuck header, by Shift+Tab or a script's focus(), is scrolled just clear of it, and focus on the header's own controls never moves the page.

Nothing is measured for the first paint, so until the page has loaded a table's header row sticks at the top and moves down under the header once measured. To keep that jump small, give the shell an estimate, which the measurement replaces:

<AppShell header={<PageHeader title="Receipts" />} style={{ '--page-sticky-top': '7.5rem' }}>

To set the offset yourself, pass measuresHeader={false} and set --page-sticky-top on the shell's style or an ancestor. Set it yourself also for a header of your own outside the shell.

The drawer

Below 1024px the rail is hidden. A SidebarTrigger in the header opens it as a drawer from the start edge. Control the drawer with isDrawerOpen and onDrawerOpenChange.

The drawer closes on Escape, a press on the scrim, the close button or any item, the window widening to 1024px, and Back or Forward. The shell cannot see the router, so anything else that navigates from inside the drawer, such as a command palette, calls closeDrawer() from useAppShell(). An app may instead close it from an effect on usePathname().

The bottom reserve

Below 1024px, with a bottomBar, the workspace pads its bottom by the tab bar's height plus the safe area, so the page never ends under the bar. The padding is one property, --app-shell-inset-block-end. Anything else docked to the bottom edge, such as a save bar, adds to it rather than declaring its own.

The example sits in a phone frame with a Bottom tab bar as bottomBar, so it shows the phone layout at any width.

Example

Skip to content

Home

Spending is on track this month.

Twelve transactions are waiting for a category.

The page keeps the bar's height clear under its end, so nothing ends under it.

Follows the page theme and corners
import { AppShell, BottomTabBar, BottomTabBarItem } from '@snapshot-ui/react';
import { ArrowLeftRight, Ellipsis, House, Target } from 'lucide-react';
import type { ReactElement } from 'react';
import { ShellHeader } from './shell-nav';

export default function AppShellPhone(): ReactElement {
  return (
    <div className="stage-phone">
      {/* A phone has no rail here, so the header draws no menu button. */}
      <AppShell
        header={<ShellHeader hasTrigger={false} />}
        bottomBar={
          <BottomTabBar aria-label="Main, phone">
            <BottomTabBarItem href="#home" icon={<House />} isCurrent>
              Home
            </BottomTabBarItem>
            <BottomTabBarItem
              href="#transactions"
              icon={<ArrowLeftRight />}
              count={12}
              countLabel="to review"
            >
              Transactions
            </BottomTabBarItem>
            <BottomTabBarItem href="#goals" icon={<Target />}>
              Goals
            </BottomTabBarItem>
            <BottomTabBarItem icon={<Ellipsis />}>More</BottomTabBarItem>
          </BottomTabBar>
        }
      >
        <div style={{ padding: '0 var(--spacing-inset) var(--spacing-inset)', overflowWrap: 'anywhere' }}>
          <p style={{ margin: '0 0 16px' }}>Spending is on track this month.</p>
          <p style={{ margin: '0 0 16px' }}>Twelve transactions are waiting for a category.</p>
          <p style={{ margin: 0 }}>
            The page keeps the bar&apos;s height clear under its end, so nothing ends under it.
          </p>
        </div>
      </AppShell>
    </div>
  );
}

Floating

floating holds chrome that outlives every page, such as an Upload tray. It renders after the workspace and the tab bar, with no wrapper of its own, so it reads the shell's offsets: a fixed part there sits above the tab bar's reserve by --app-shell-inset-block-end, and clears the rail by --app-shell-sidebar. It is outside <main>, so it is never a landmark inside the page's main content, and since the shell lives in your root layout, a client navigation keeps it and whatever it holds, such as uploads in flight.

<AppShell sidebar={<Nav />} bottomBar={<Tabs />} floating={<UploadTray items={uploads.items} />}>
  {children}
</AppShell>

Usage

AppShell is a client component that a server layout can render. Every prop except the callbacks is serializable. The server layout passes the slots and reads the cookie, and a client wrapper adds the callbacks, as above.

  • useAppShell() gives the shell's state to its parts and to your own header or palette. It throws outside a shell.
  • Links in the rail navigate through React Aria's RouterProvider, which Routing sets up once for the app.

Props

className and ref go on the root, and className is merged after the library's class.

bottomBar
ReactNode

The bottom tab bar. Below 1024px the workspace reserves room under its content for it.

children
ReactNode

The page, rendered inside <main>.

className
string

defaultDrawerOpen
boolean

Uncontrolled: the first value.

Default: false

defaultSidebarCollapsed
boolean

Uncontrolled: the first value. A server layout passes what its cookie says, so the first paint is right.

Default: false

floating
ReactNode

Floating chrome that outlives every page, such as an UploadTray: rendered after the workspace and the tab bar, outside <main>, so a client navigation keeps it and it is never a landmark inside main.

header
ReactNode

The page header, such as a PageHeader: the first part of <main>, so the skip link lands on the page's title. Sticky to the top: once hydrated, its height is the page's --page-sticky-top, under which a DataTable's header row and a SidePanel stick, and a control that focus lands on under it is scrolled clear of it.

isDrawerOpen
boolean

Controlled: whether the rail is open as a drawer. Only meaningful below 1024px.

isSidebarCollapsed
boolean

Controlled: whether the rail is collapsed to icons. Pair it with onSidebarCollapsedChange.

mainId
string

The id of <main>, which the skip link targets. Default from useId.

measuresHeader
boolean

Whether the shell measures its header once hydrated and writes the height as --page-sticky-top on <main>. Pass false to set --page-sticky-top yourself, on the shell's style or an ancestor. While it measures, a --page-sticky-top on the shell's style is an estimate for the first paint, which the measurement replaces.

Default: true

onDrawerOpenChange
((isOpen: boolean) => void)

onSidebarCollapsedChange
((isCollapsed: boolean) => void)

sidebar
ReactNode

The rail: a Sidebar. Fixed and floating; the workspace is offset by its width from 1024px up.

skipLinkLabel
string

The skip link's text.

Default: 'Skip to content'

closeDrawer
() => void

isDrawerOpen
boolean

isSidebarCollapsed
boolean

Whether the rail is drawn collapsed: the reader's choice, or folded for an open SidePanel under 1440px.

mainId
string

The id of <main>.

openDrawer
() => void

setSidebarCollapsed
(isCollapsed: boolean) => void

The reader's choice. Expanding a rail a SidePanel folded keeps it expanded until that panel closes.

Accessibility

The shell renders one <main>, and the skip link to it is the first tab stop. The header is the first part of <main>, so a header element there is not a banner landmark, and the page's title is in the main landmark. A control that focus moves to is never left under the stuck header: once the page has loaded, the shell scrolls it just clear. The rail and the tab bar each have a named nav, and only one is ever in the accessibility tree: the rail from 1024px up, the tab bar below. The rail root and header wrapper carry no landmark role. While the drawer is open, the page behind it is hidden from assistive technology and does not scroll.

TabThe skip link first, then the rail, the header and the page.
EnterOn the skip link, moves focus to the main content, which starts with the header.

Design rules

What keeps the shell in the system.

Pass the cookie's value as the default, so the first paint is the right width.

Keep the header's look to the header: the shell only makes it sticky.

Call closeDrawer() from anything that navigates from inside the drawer.

Never measure the viewport for the first paint.

Never write data-sidebar="expanded". The shell writes collapsed or nothing.

Never put a second <main> in the page. The shell draws the one.

esc

↑ ↓ to move↵ to open