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
Example
Home
Spending is on track this month.
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
Example
Home
Spending is on track this month.
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 collapsed state and the cookie
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 skip link and main
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.
skipLinkLabelreplaces its text, "Skip to content".mainIdsets the id of<main>. Without it, the id comes fromuseId.useAppShell().mainIdgives 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
Example
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.
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'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 |
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 |
header ReactNode | The page header, such as a PageHeader: the first part of |
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 |
mainId string | The id of |
measuresHeader boolean | Whether the shell measures its header once hydrated and writes the height as 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 |
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.
| Tab | The skip link first, then the rail, the header and the page. |
|---|---|
| Enter | On 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.