App Shell
A shared dashboard layout with skip-linked sidebar, header, and scrollable main region composed from VegaStack navigation primitives.
- Status
- Since
0.1.0- Accessibility pattern
- landmark regions + skip link
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for App Shell before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify app-shellInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/app-shellRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/dropdown-menu, @vegastack/page-layout, @vegastack/sidebar, @vegastack/skeleton, @vegastack/use-media-query.
Usage
import {
AppShell,
AppShellContent,
AppShellHeader,
AppShellSidebar,
} from "@/components/ui/app-shell";
import { SidebarContent, SidebarHeader } from "@/components/ui/sidebar";
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbPage,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
<AppShell defaultOpen>
<AppShellSidebar>
<SidebarHeader>…logo / workspace switcher…</SidebarHeader>
<SidebarContent>…nav groups…</SidebarContent>
</AppShellSidebar>
<div className="flex h-svh min-w-0 flex-1 flex-col">
<AppShellHeader actions={<Button size="sm">New agent</Button>}>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Dashboard</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</AppShellHeader>
<AppShellContent>…page content…</AppShellContent>
</div>
</AppShell>;Anatomy
AppShell is a composition layer over Sidebar (+ Sheet/useIsMobile for its mobile behavior) —
not a new primitive. It wraps SidebarProvider and renders a skip-to-content link as the first
focusable element; you compose AppShellSidebar as one child and your own content column (a plain
flex h-svh flex-col <div>) wrapping AppShellHeader + AppShellContent as the other:
<AppShell defaultOpen>
<AppShellSidebar variant="sidebar" collapsible="icon">
<SidebarHeader>…</SidebarHeader>
<SidebarContent>…</SidebarContent>
<SidebarFooter>…</SidebarFooter>
</AppShellSidebar>
<div className="flex h-svh min-w-0 flex-1 flex-col">
<AppShellHeader actions={<>…buttons…</>}>
<Breadcrumb>…</Breadcrumb>
</AppShellHeader>
<AppShellContent variant="sidebar">
<AppShellPage size="default">…</AppShellPage>
</AppShellContent>
</div>
</AppShell>AppShell— the root (data-slot="app-shell"). WrapsSidebarProvider, forwardingdefaultOpen/open/onOpenChange— every prop upstream's provider takes. Renders the skip link.AppShellSidebar— a thin wrapper overSidebarthat passesvariant/collapsible/sidestraight through and adds the<nav>landmark upstream leaves to the page: adata-slot="app-shell-sidebar"element wrapping the rail's children, defaulting toaria-label="Main navigation". It sits INSIDE the rail so that below the mobile breakpoint — where upstream moves the rail into aSheet— the landmark appears exactly when the navigation does, instead of standing empty in the document.AppShellHeader— a<header>bannerlandmark (data-slot="app-shell-header"): always-visibleSidebarTrigger+ amin-w-0middle slot (children) + ashrink-0actionsslot, padded by the page gutter (--page-gutter) so its edges line up with the page's.AppShellContent— the<main tabIndex={-1}>data-slot="app-shell-content"— this shell's skip-link target, carrying@container/app-shell-contentfor width-aware grids. Itsidis generated per shell byAppShell(contentIdpins it), never a fixed literal.AppShellPage— the page container insideAppShellContent(data-slot="app-shell-page",data-size): the page gutter, agap-6rhythm and theprose/default/fullwidth.AppShellSkeleton— a full-shell loading composition forloading.tsx(data-slot="app-shell-skeleton").
Why no AppShellBody/column component? Keeping the content column a plain, undecorated
<div> you write yourself keeps AppShellHeader's <header> a true SIBLING of
AppShellContent's <main> — never nested inside it, which is what lets the header keep its
banner landmark role. A wrapping component that owned both would tempt collapsing them into one
element, silently losing that landmark.
Examples
Variants
AppShellSidebar passes variant straight through to Sidebar (sidebar / floating / inset).
Pass the SAME variant to AppShellContent to keep the shell visually consistent — unlike
Sidebar + SidebarInset's CSS peer selector, AppShellContent applies its panel treatment
directly via the prop (see the API reference for why). Under inset it paints exactly what
SidebarInset paints — m-2 ms-0 rounded-xl shadow-sm from md up, no border — so the two agree
on their own corner.
Page container
Put each page's content in AppShellPage inside AppShellContent. It is the one place a page's
width and gutters come from: a column padded by the page gutter (--page-gutter — 16px, 24px
from 640px, 32px from 1024px, 48px from 1920px, the header's own padding) with a gap-6 rhythm between the
PageHeader and each section. size picks the width — prose (720px of content, centred) for
forms and settings, default (1280px, centred) for lists, dashboards and record pages, and full
(edge to edge, fills the height) for boards and canvases; narrow is prose's deprecated old
name. Choose it per route from one map — see Page layout. It
renders a plain <div>, because AppShellContent is already the main landmark.
Profile
How your name and photo appear to your team.
Display name
A prose page holds 720px of content and keeps the same page gutter as every other page.
<AppShellContent>
<AppShellPage size="prose">
<PageHeader title="Profile" />
<ProfileForm />
</AppShellPage>
</AppShellContent>Section measure
AppShellSection holds one region of a page at its own measure, with no max-w-* of its own.
measure="prose" caps it at 720px — a settings tab under a default-width header and tabs — and
full (the default) takes the page's width. align places a prose section: start (the default)
keeps it under the header's start edge.
Product
A space's settings tab.
Name
The section caps at 720px and starts where the header starts.
align="center" centres it in the page, so a settings tab reads like a settings page
(AppShellPage size="prose"). align is ignored at full.
Width follows the content, not the route. measure="default" caps a section at a default
page's content width and centres it (its align defaults to center), so a full frame that
hosts several kinds of content — a folder list, a document, a grid on one route — keeps its list
at exactly the width every other list has, while the grid beside it fills.
Contracts
A folder in a full-width frame.
The list caps at a default page's content width and sits in the middle.
<AppShellPage size="full">
<AppShellSection measure="default">
<PageHeader title="Contracts" />
<DataList … />
</AppShellSection>
</AppShellPage><AppShellPage>
<PageHeader title="Product" tabs={tabs} />
<AppShellSection measure="prose" align="center">
<SpaceSettings />
</AppShellSection>
</AppShellPage>Product
A space's settings tab.
Name
The section caps at 720px and sits in the middle of the page.
Bleed
AppShellBleed is a region of the page that reaches the page's side edges and bottom, past the page
gutter: a grid under the PageHeader whose scroll bars should rest at the edge of the page's
region instead of floating inside the gutter. It owns the three negative margins, so no wrapper
carries a gutter class of its own. Put it directly inside the page, after the header; active={false}
keeps the gutter (a grid that lays out as cards on a phone).
<AppShellPage size="full">
<PageHeader title="Schedule" />
<AppShellBleed>
<DataGrid fill … />
</AppShellBleed>
</AppShellPage>Header tools by width
AppShellHeader is an inline-size container named app-shell-header, so tools in it compact by
the header's own width — which counts the sidebar being open or collapsed — with container
queries: no measuring script and no first-paint flash.
<AppShellHeader
actions={
<Button size="sm" variant="ghost" aria-label="Share">
<ShareIcon />
<span className="hidden @2xl/app-shell-header:inline">Share</span>
</Button>
}
>
…
</AppShellHeader>Two parts cover the common cases without a class of your own. AppShellHeaderSlot is a fixed
32px place for something that arrives late or changes shape — a save status, a connection icon —
reserved from the first paint so the buttons beside it never move (a tooltip trigger can render
as it). AppShellHeaderShow when="narrow" shows its content only while the bar is under 40rem,
and when="wide" only from 40rem.
<AppShellHeader
actions={
<>
<AppShellHeaderShow when="narrow">
<AvatarStack people={here} max={2} />
</AppShellHeaderShow>
<AppShellHeaderSlot>
<AutoSaveIndicator variant="icon" status={status} />
</AppShellHeaderSlot>
</>
}
>
…
</AppShellHeader>Actions that give way. AppShellHeaderActions takes the page's actions as data and moves
each one into a single ⋮ menu when the bar is narrower than its overflow width — laptop
(55rem), tablet (40rem), or never for the page's one primary action — and drops the labels
to icons below 40rem. menu entries always live in the ⋮. Nothing hides without a way to reach
it, and the page writes no container query of its own. render takes a router link for an
action that navigates.
<AppShellHeader
actions={
<AppShellHeaderActions
leading={<AvatarStack people={here} max={3} />}
actions={[
{
id: "share",
label: "Share",
icon: <Share2 />,
onSelect: share,
overflow: "never",
},
{
id: "watch",
label: "Watch",
icon: <Bell />,
onSelect: watch,
overflow: "laptop",
},
]}
menu={[
{ id: "copy", label: "Copy link", icon: <Link2 />, onSelect: copy },
]}
menuLabel="Task actions"
/>
}
>
…
</AppShellHeader>Skeleton
AppShellSkeleton composes a full loading shell — a sidebar column, a header line, and a
stat-card + chart-placeholder content region — server-safe, so it drops straight into a Next.js
loading.tsx with no 'use client' needed.
// app/(dashboard)/loading.tsx — a Server Component.
import { AppShellSkeleton } from "@/components/ui/app-shell";
export default function Loading() {
return <AppShellSkeleton navItemCount={6} statCardCount={4} />;
}Loading a page inside the shell. A route's loading.tsx shows AppShellProgress — a 2px
indeterminate bar stuck to the top of the content however far it was scrolled, at once — and its own skeleton wrapped in
motion-reveal (a @vegastack/design-tokens utility): the skeleton waits 150ms
(--reveal-delay) before it fades in, so a fast load never flashes a skeleton and a slow one is
never a blank page while it waits. One skeleton per load, in the page's own shape.
// app/(app)/tasks/loading.tsx
export default function Loading() {
return (
<>
<AppShellProgress label="Loading tasks" />
<div className="motion-reveal">
<TasksSkeleton />
</div>
</>
);
}Mobile
Below 768px — upstream's one fixed breakpoint, read through useIsMobile, with no prop to retune
it — Sidebar swaps to a Sheet drawer. AppShellHeader already renders SidebarTrigger OUTSIDE
AppShellSidebar (it's a sibling in the content column, not nested inside the rail), so the one
control that opens the mobile sheet is always reachable, with zero extra composition on your part.
The header's middle slot truncates (min-w-0 flex-1) instead of wrapping — collapse a long trail
yourself with BreadcrumbEllipsis on narrow screens.
Switch the preview's width toggle (the toolbar's phone icon) to mobile to watch the rail collapse into the Sheet — open it from the header trigger. This is viewport-driven, so a real narrow browser does the same on its own; the toggle only constrains a container, so this demo forces the branch to make it visible without resizing your window.
Tablet rail
From 768px up to 1023px an icon rail rests as icons. Counts stay at each icon's top-end corner (a
dot past two characters), railSeparators rules a hairline between the groups once their labels
fold away, and a nested list such as Spaces opens beside its row through
SidebarMenuFlyout railOnly.
Phone bar
On a phone the sidebar is a drawer, so what it holds is two taps away. mobileActions puts the
most frequent jumps in the bar — Search, and the Inbox with its count — each an
AppShellHeaderMobileAction of 40px, before actions, so the page's primary stays rightmost; the
sidebar trigger grows to 40px with them. AppShellFold folds the drawer's footer entries (Docs,
Trash, Settings) into the profile menu on a phone: wrap the sidebar's copy in place="sidebar" and
the menu's in place="menu", and exactly one renders. Narrow the window below 768px to see both.
Route-change focus
AppShell is deliberately router-agnostic — it has no dependency on Next.js/React Router and
never imports one. Moving keyboard focus to the new page's heading on a client-side route change
is standard SPA accessibility practice, and it's the DOWNSTREAM app's responsibility: focus your
PageHeader's title (give it tabIndex={-1}) or AppShellContent itself — pass contentId to
AppShell when you need a known id to focus — keyed on the router's pathname:
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
function RouteFocus() {
const pathname = usePathname();
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
return (
<h1 ref={headingRef} tabIndex={-1}>
…
</h1>
);
}Static shells
A prerendered or cached shell cannot read the sidebar cookie on the server, so a collapsed sidebar
would paint expanded and jump on hydration. Put SidebarStateScript in the root layout's <head>
with the same collapsible as your AppShellSidebar, and drive AppShell from
useSidebarCookieOpen when you control it. The script marks the panel collapsed before first
paint, the hook starts from that mark and writes the cookie, and the controlled shell writes none
itself (LAY-13). The mechanism is documented on Sidebar.
"use client";
import { AppShell } from "@/components/ui/app-shell";
import { useSidebarCookieOpen } from "@/components/ui/sidebar";
export function AppFrame({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useSidebarCookieOpen();
return (
<AppShell open={open} onOpenChange={setOpen}>
{children}
</AppShell>
);
}API Reference
AppShell
| Prop | Type | Default | Description |
|---|---|---|---|
collapseRail | boolean | true | Fold an open icon rail (AppShellSidebar collapsible="icon") when the window narrows into the
tablet span (768–1023px, RAIL_COLLAPSED_QUERY), and end the fold when it widens out of it.
The fold is display only — it writes no cookie and calls no onOpenChange — so the person's
own choice comes back on the way out, after a reload, and with the first toggle. Only a live
change of width folds, never the first paint (SidebarStateScript's collapsedWhen decides
the start). An off-canvas sidebar is never folded. false turns it off. |
contentId | string | — | The id this shell's AppShellContent claims and this shell's skip link targets. Generated
with React.useId() when omitted, so two shells on one page never collide.
Pass it when the id has to be stable and known — a documented deep link, an external
aria-controls, or a test harness. Setting id through {...props} on AppShell or on
AppShellContent does NOT rewire the skip link; this prop is the one that does. |
defaultOpen | boolean | true | Initial sidebar open state when uncontrolled — forwarded to SidebarProvider. |
keyboardShortcut | string | false | "b" | The key that toggles the sidebar with ⌘/Ctrl, or false to turn the shortcut off —
forwarded to SidebarProvider. |
onOpenChange | ((open: boolean) => void) | — | Called whenever the sidebar's open state changes — forwarded to SidebarProvider. |
open | boolean | — | Controlled sidebar open state — forwarded to SidebarProvider. Pair with onOpenChange. |
skipLinkLabel | string | 'Skip to content' | Accessible label for the skip-to-content link — the first focusable element in the shell,
always present in the DOM (sr-only until focused). |
Data attributes and CSS variables on AppShell
| Attribute | Values |
|---|---|
data-rail-folded | "" |
data-slot | "app-shell" | "app-shell-skip-link" |
AppShellSidebar
| Prop | Type | Default | Description |
|---|---|---|---|
collapsible | "icon" | "none" | "offcanvas" | — | |
railSeparators | boolean | false | In the collapsed icon rail, rule a hairline between consecutive SidebarGroups (in the 8px
gap their padding already leaves), so the rail's icons read in their groups once the group
labels have folded away. The expanded sidebar and the phone drawer are unchanged. |
side | "left" | "right" | — | |
variant | "floating" | "inset" | "sidebar" | — |
Data attributes and CSS variables on AppShellSidebar
| Attribute | Values |
|---|---|
data-rail-separators | "" |
data-slot | "app-shell-sidebar" |
AppShellHeader
| Prop | Type | Default | Description |
|---|---|---|---|
actions | React.ReactNode | — | Right-aligned, shrink-0 end slot — page-level actions (typically one or more Buttons or
a menu trigger). Omit to hide the slot entirely. |
mobileActions | React.ReactNode | — | The phone's own tools, before actions (so the page's primary stays rightmost): Search and
the Inbox with its count, each an AppShellHeaderMobileAction. Shown only below 768px, where
the sidebar — which holds them at every other width — is a drawer; with it the sidebar
trigger is a 40px target on a phone too. Omit to keep the bar as it is. |
Data attributes and CSS variables on AppShellHeader
| Attribute | Values |
|---|---|
data-slot | "app-shell-header" | "app-shell-header-actions" | "app-shell-header-middle" | "app-shell-header-mobile-actions" |
AppShellHeaderMobileAction
| Prop | Type | Default | Description |
|---|---|---|---|
icon* | React.ReactNode | — | The tool's icon. |
label* | string | — | The tool's name ("Search", "Inbox") — its accessible name; the button shows the icon alone. |
count | number | — | A count at the icon's top-end corner ("15"); nothing at 0 or below. |
countLabel | string | — | The count's words in the name: "Inbox 15 unread". |
countMax | number | 99 | The highest count shown as a number; above it the pill reads "99+". |
dot | boolean | false | A dot at the corner instead of a number — something new, uncounted. A count wins. |
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the button's width does not move and its accessible name survives
(API-5, A11Y-12). |
Data attributes and CSS variables on AppShellHeaderMobileAction
| Attribute | Values |
|---|---|
data-slot | "app-shell-header-mobile-action" | "app-shell-header-mobile-action-count" | "app-shell-header-mobile-action-dot" |
AppShellFold
| Prop | Type | Default | Description |
|---|---|---|---|
children* | React.ReactNode | — | The entries, in the shape their place needs (sidebar rows, or menu items). |
place* | "menu" | "sidebar" | — | Which copy this is: sidebar — the entries' place in the sidebar, left out of the phone
drawer; menu — their place in the profile menu, rendered only while the sidebar is the phone
drawer. |
AppShellHeaderActions
| Prop | Type | Default | Description |
|---|---|---|---|
actions | AppShellHeaderAction[] | [] | The bar's actions, start to end; each moves into ⋮ when the bar is narrower than its overflow. |
leading | React.ReactNode | — | Content kept in the bar before the actions (a presence stack, a save status). |
menu | AppShellHeaderAction[] | [] | Entries that live only in the ⋮ menu, after the ones that overflowed. |
menuLabel | string | "More actions" | The ⋮ trigger's accessible name. |
Data attributes and CSS variables on AppShellHeaderActions
| Attribute | Values |
|---|---|
data-action | mirrors a prop or state value |
data-compact | "" |
data-slot | "app-shell-header-actions-group" | "app-shell-header-actions-menu" |
Each entry of actions and menu is an AppShellHeaderAction:
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable key. |
label* | string | — | The visible label while the bar has room, and the accessible name everywhere. |
destructive | boolean | — | A destructive menu entry (Move to Trash) — the menu's destructive variant. |
disabled | boolean | — | Shown but unavailable. |
icon | React.ReactNode | — | The icon — shown alone when the bar is narrow, and beside the label in the ⋮ menu. |
onSelect | ((event: React.MouseEvent<HTMLElement>) => void) | — | Runs on click or menu selection. |
overflow | "laptop" | "never" | "tablet" | "tablet" | When the bar narrows below this width it moves into the ⋮ menu: laptop (55rem — the bar
beside an open sidebar on a laptop), tablet (40rem). never keeps it in the bar (the
page's one primary action), icon-only below 40rem. |
render | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | — | The element to render as — a router link (<Link href="/x" />) — for the button and for its
⋮ menu item alike. |
variant | "default" | "ghost" | "outline" | "secondary" | "ghost" | Button variant while it is in the bar. |
AppShellProgress
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Loading" | The progress bar's accessible name. |
Data attributes and CSS variables on AppShellProgress
| Attribute | Values |
|---|---|
data-slot | "app-shell-progress" |
AppShellContent
| Prop | Type | Default | Description |
|---|---|---|---|
landmark | "main" | "region" | 'main' | Which landmark this region claims. main (the default) is what a real application wants —
one <main> per document, and the skip link's target.
region renders a <div role="region"> instead, for the case where the shell is EMBEDDED in
a page that already owns a <main>: a docs preview, a design gallery, a shell shown inside a
larger document. Two <main> elements in one document is a real defect (axe
landmark-no-duplicate-main), and it was one this system's own showcase kept hitting. A
region needs an accessible name to be exposed as a landmark at all, so pass aria-label
with it; without one it is simply a plain container, which is also a correct outcome here. |
variant | "floating" | "inset" | "sidebar" | 'sidebar' | Panel treatment mirroring the sibling Sidebar/AppShellSidebar's variant — pass the SAME
value on both so the shell reads as one consistent layout. Applied directly as a prop here
(not shadcn's SidebarInset + CSS peer selector) — see the component doc for why. |
Data attributes and CSS variables on AppShellContent
| Attribute | Values |
|---|---|
data-slot | "app-shell-content" |
data-variant | mirrors a prop or state value |
AppShellPage
| Prop | Type | Default | Description |
|---|---|---|---|
size | "narrow" | PageWidth | 'default' | The page's width — prose (720px of content, centred) for forms and settings, default
(1280px, centred) for lists, dashboards and record pages, full (edge to edge, fills the
height) for boards and canvases. narrow is the deprecated old name of prose — it renders
prose and reports data-size="prose"; it goes in the next minor. |
Data attributes and CSS variables on AppShellPage
| Attribute | Values |
|---|---|
data-size | mirrors a prop or state value |
data-slot | "app-shell-page" |
AppShellBleed
| Prop | Type | Default | Description |
|---|---|---|---|
active | boolean | true | Whether the content reaches the page's edges. Off, it keeps the page gutter (a grid that lays out as cards on a phone has no scroll bars to rest at the edge). |
Data attributes and CSS variables on AppShellBleed
| Attribute | Values |
|---|---|
data-active | "false" | "true" |
data-slot | "app-shell-bleed" |
AppShellSection
| Prop | Type | Default | Description |
|---|---|---|---|
align | "center" | "start" | 'center' for `default`, 'start' for `prose` | Where a narrower measure sits in the page: start under the header's start edge, or
center in the middle of the page. Ignored at full. |
measure | "default" | "full" | "prose" | 'full' | The section's measure inside the page. prose caps it at 720px (45rem) — a settings tab
under a 1280px page header. default caps it at the content width of a default page
(1280px less the gutters), so a list inside a full frame — a folder in a frame that also
hosts documents and grids — lines up with every other list. full takes the page's whole
width. |
Data attributes and CSS variables on AppShellSection
| Attribute | Values |
|---|---|
data-align | mirrors a prop or state value |
data-measure | mirrors a prop or state value |
data-slot | "app-shell-section" |
AppShellSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
navItemCount | number | 5 | Number of nav-row placeholders in the sidebar column. |
statCardCount | number | 4 | Number of stat-card placeholders in the content region. |
Data attributes and CSS variables on AppShellSkeleton
| Attribute | Values |
|---|---|
data-slot | "app-shell-skeleton" | "app-shell-skeleton-nav-row" | "app-shell-skeleton-stats" |
Accessibility
- Skip link. The very first focusable element in the shell (
sr-only focus:not-sr-only), targeting THIS shell'sAppShellContent. Tab once on page load to reach it. The target id is generated withReact.useId()and shared down, so a page holding several shells gives each one its own target rather than sending every link to the first; passcontentIdonAppShellto pin it. SettingidonAppShellContentmoves the element but does not rewire the link. - Landmark trio.
AppShellHeaderis abanner(<header>, never nested inside<main>),AppShellSidebaris anavigation(<nav aria-label="Main navigation">, overridable), andAppShellContentis the singlemainon the page. If the shell is embedded in a page that already owns a<main>— a docs preview, a gallery, a shell shown inside a larger document — passlandmark="region"(with anaria-label) so it renders a<div role="region">instead. Two<main>elements in one document is a real defect; this escape exists so the answer is never to delete the landmark from the component.SidebarInsetcarries the same prop. Every preview on this page is an embedded shell and therefore useslandmark="region"— this page already owns the site's one<main>, so no preview here can demonstrate themainlandmark, and the skip links you can try below land on aregion. In a real application the default applies and the target is a<main>. - Mobile trigger.
SidebarTriggeralways renders inAppShellHeader— below the mobile breakpoint it's the only way to open the sidebar, and it's never nested inside the collapsible rail it controls (seesidebar.tsx's own composition note). - Phone tools. Each
AppShellHeaderMobileActionis a 40px button named by its label, with its count in the name ("Inbox 15 unread"); the pill on the icon is decorative. - Reduced motion. The sidebar's collapse/expand and the mobile sheet's slide-in both route
through the design system's centralized
prefers-reduced-motionblock — no shell-specific motion to opt out of. - Route-change focus is the host app's responsibility (see above) —
AppShellstays router-agnostic and cannot own it.
| Key | Action |
|---|---|
| Tab (on page load) | Focus the skip link. |
| Enter on the skip link | Move focus to this shell's AppShellContent region. |
| ⌘/Ctrl+B | Toggle the sidebar (desktop) or the mobile sheet. Upstream's SidebarProvider owns the chord. |
| Contract | States tested |
|---|---|
| Behaviour | default, loading, open, mobile-actions, phone-fold, rail-separators |
| Accessibility | busy, focus-visible, labeled, semantic-html |
| Visual | default, focus, loading |