Skip to content
Component installs need the registry setup
VegaStack Design

App Shell

A shared dashboard layout with skip-linked sidebar, header, and scrollable main region composed from VegaStack navigation primitives.

Status
stable
Since
0.1.0
Accessibility pattern
landmark regions + skip link

Last updated

Skip to content

Active agents

—

Tasks today

—

API calls (24h)

—

Avg. response

—

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-shell

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/app-shell

Run 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 — data-slot="app-shell" | "app-shell-skip-link"
AppShellBleed — data-slot="app-shell-bleed"
AppShellContent — data-slot="app-shell-content"
AppShellHeader — data-slot="app-shell-header" | "app-shell-header-actions" | "app-shell-header-middle" | "app-shell-header-mobile-actions"
AppShellHeaderActions — data-slot="app-shell-header-actions-group" | "app-shell-header-actions-menu"
AppShellHeaderShow — data-slot="app-shell-header-show"
AppShellHeaderSlot — data-slot="app-shell-header-slot"
AppShellPage — data-slot="app-shell-page"
AppShellProgress — data-slot="app-shell-progress"
AppShellSection — data-slot="app-shell-section"
AppShellSidebar — data-slot="app-shell-sidebar"
AppShellSkeleton — data-slot="app-shell-skeleton" | "app-shell-skeleton-nav-row" | "app-shell-skeleton-stats"
AppShellFold
AppShellHeaderMobileAction — data-slot="app-shell-header-mobile-action" | "app-shell-header-mobile-action-count" | "app-shell-header-mobile-action-dot"
<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"). Wraps SidebarProvider, forwarding defaultOpen/open/onOpenChange — every prop upstream's provider takes. Renders the skip link.
  • AppShellSidebar — a thin wrapper over Sidebar that passes variant/collapsible/side straight through and adds the <nav> landmark upstream leaves to the page: a data-slot="app-shell-sidebar" element wrapping the rail's children, defaulting to aria-label="Main navigation". It sits INSIDE the rail so that below the mobile breakpoint — where upstream moves the rail into a Sheet — the landmark appears exactly when the navigation does, instead of standing empty in the document.
  • AppShellHeader — a <header> banner landmark (data-slot="app-shell-header"): always-visible SidebarTrigger + a min-w-0 middle slot (children) + a shrink-0 actions slot, 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-content for width-aware grids. Its id is generated per shell by AppShell (contentId pins it), never a fixed literal.
  • AppShellPage — the page container inside AppShellContent (data-slot="app-shell-page", data-size): the page gutter, a gap-6 rhythm and the prose / default / full width.
  • AppShellSkeleton — a full-shell loading composition for loading.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.

Skip to content
Dashboard

The content region is the rounded panel — pass the same variant to both AppShellSidebar and AppShellContent.

Skip to content
Dashboard

floating styles the rail itself — the content region needs no matching variant.

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.

Skip to content
Quarterly plan
Skip to content
Quarterly plan
<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.

Skip to content
Mobile dashboard

Use the menu trigger to open navigation over this content.

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.

Skip to content
Tablet rail

Hover Spaces for its flyout; expand the rail with the header button.

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.

Skip to content
Settings

Narrow the window below 768px: Search and Inbox join the bar, and Docs and Settings move into the profile menu.

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.

components/app-frame.tsx
"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

PropTypeDefaultDescription
collapseRailbooleantrueFold 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.
contentIdstring—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.
defaultOpenbooleantrueInitial sidebar open state when uncontrolled — forwarded to SidebarProvider.
keyboardShortcutstring | 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.
openboolean—Controlled sidebar open state — forwarded to SidebarProvider. Pair with onOpenChange.
skipLinkLabelstring'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

AttributeValues
data-rail-folded""
data-slot"app-shell" | "app-shell-skip-link"

AppShellSidebar

PropTypeDefaultDescription
collapsible"icon" | "none" | "offcanvas"—
railSeparatorsbooleanfalseIn 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

AttributeValues
data-rail-separators""
data-slot"app-shell-sidebar"

AppShellHeader

PropTypeDefaultDescription
actionsReact.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.
mobileActionsReact.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

AttributeValues
data-slot"app-shell-header" | "app-shell-header-actions" | "app-shell-header-middle" | "app-shell-header-mobile-actions"

AppShellHeaderMobileAction

PropTypeDefaultDescription
icon*React.ReactNode—The tool's icon.
label*string—The tool's name ("Search", "Inbox") — its accessible name; the button shows the icon alone.
countnumber—A count at the icon's top-end corner ("15"); nothing at 0 or below.
countLabelstring—The count's words in the name: "Inbox 15 unread".
countMaxnumber99The highest count shown as a number; above it the pill reads "99+".
dotbooleanfalseA dot at the corner instead of a number — something new, uncounted. A count wins.
loadingboolean—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

AttributeValues
data-slot"app-shell-header-mobile-action" | "app-shell-header-mobile-action-count" | "app-shell-header-mobile-action-dot"

AppShellFold

PropTypeDefaultDescription
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

PropTypeDefaultDescription
actionsAppShellHeaderAction[][]The bar's actions, start to end; each moves into ⋮ when the bar is narrower than its overflow.
leadingReact.ReactNode—Content kept in the bar before the actions (a presence stack, a save status).
menuAppShellHeaderAction[][]Entries that live only in the ⋮ menu, after the ones that overflowed.
menuLabelstring"More actions"The ⋮ trigger's accessible name.

Data attributes and CSS variables on AppShellHeaderActions

AttributeValues
data-actionmirrors 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:

PropTypeDefaultDescription
id*string—Stable key.
label*string—The visible label while the bar has room, and the accessible name everywhere.
destructiveboolean—A destructive menu entry (Move to Trash) — the menu's destructive variant.
disabledboolean—Shown but unavailable.
iconReact.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.
renderReact.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

PropTypeDefaultDescription
labelstring"Loading"The progress bar's accessible name.

Data attributes and CSS variables on AppShellProgress

AttributeValues
data-slot"app-shell-progress"

AppShellContent

PropTypeDefaultDescription
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

AttributeValues
data-slot"app-shell-content"
data-variantmirrors a prop or state value

AppShellPage

PropTypeDefaultDescription
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

AttributeValues
data-sizemirrors a prop or state value
data-slot"app-shell-page"

AppShellBleed

PropTypeDefaultDescription
activebooleantrueWhether 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

AttributeValues
data-active"false" | "true"
data-slot"app-shell-bleed"

AppShellSection

PropTypeDefaultDescription
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

AttributeValues
data-alignmirrors a prop or state value
data-measuremirrors a prop or state value
data-slot"app-shell-section"

AppShellSkeleton

PropTypeDefaultDescription
navItemCountnumber5Number of nav-row placeholders in the sidebar column.
statCardCountnumber4Number of stat-card placeholders in the content region.

Data attributes and CSS variables on AppShellSkeleton

AttributeValues
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's AppShellContent. Tab once on page load to reach it. The target id is generated with React.useId() and shared down, so a page holding several shells gives each one its own target rather than sending every link to the first; pass contentId on AppShell to pin it. Setting id on AppShellContent moves the element but does not rewire the link.
  • Landmark trio. AppShellHeader is a banner (<header>, never nested inside <main>), AppShellSidebar is a navigation (<nav aria-label="Main navigation">, overridable), and AppShellContent is the single main on 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 — pass landmark="region" (with an aria-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. SidebarInset carries the same prop. Every preview on this page is an embedded shell and therefore uses landmark="region" — this page already owns the site's one <main>, so no preview here can demonstrate the main landmark, and the skip links you can try below land on a region. In a real application the default applies and the target is a <main>.
  • Mobile trigger. SidebarTrigger always renders in AppShellHeader — below the mobile breakpoint it's the only way to open the sidebar, and it's never nested inside the collapsible rail it controls (see sidebar.tsx's own composition note).
  • Phone tools. Each AppShellHeaderMobileAction is 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-motion block — no shell-specific motion to opt out of.
  • Route-change focus is the host app's responsibility (see above) — AppShell stays router-agnostic and cannot own it.
KeyAction
Tab (on page load)Focus the skip link.
Enter on the skip linkMove focus to this shell's AppShellContent region.
⌘/Ctrl+BToggle the sidebar (desktop) or the mobile sheet. Upstream's SidebarProvider owns the chord.
ContractStates tested
Behaviourdefault, loading, open, mobile-actions, phone-fold, rail-separators
Accessibilitybusy, focus-visible, labeled, semantic-html
Visualdefault, focus, loading

Do / Don't

Do
Compose the landmark trio once, keep the mobile trigger in AppShellHeader, and preserve the skip-link target.
Don't
Hand-roll another sidebar/header/main shell or nest the banner landmark inside main content.

On this page