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
Add App Shell from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/app-shellThe same command installs the registry items it composes: @vegastack/sidebar, @vegastack/skeleton.
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 keyboardShortcut mobileBreakpoint={768}>
<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">…</AppShellContent>
</div>
</AppShell>AppShell— the root (data-slot="app-shell"). WrapsSidebarProvider, forwardingdefaultOpen/open/onOpenChange/mobileBreakpoint/keyboardShortcut. Renders the skip link.AppShellSidebar— a thin wrapper overSidebar(data-slot="app-shell-sidebar") that defaultsaria-label="Main navigation"and passesvariant/collapsible/sidestraight through.AppShellHeader— a<header>bannerlandmark (data-slot="app-shell-header"): always-visibleSidebarTrigger+ amin-w-0middle slot (children) + ashrink-0actionsslot.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.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).
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} />;
}Mobile
Below mobileBreakpoint (768px by default), 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 — pair it with BreadcrumbTrail's maxItems for long trails 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.
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>
);
}API Reference
AppShell
| Prop | Type | Default | Description |
|---|---|---|---|
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 | boolean | true | Keyboard shortcut that toggles the sidebar — forwarded to SidebarProvider. true uses
Cmd/Ctrl+B, pass a key string to customize, or false to disable. |
mobileBreakpoint | number | 768 | Viewport width (px) below which the sidebar switches into the mobile Sheet — 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-slot | "app-shell" | "app-shell-skip-link" |
AppShellSidebar
| Prop | Type | Default | Description |
|---|---|---|---|
collapsible | "icon" | "none" | "offcanvas" | 'icon' | How the rail collapses when state is "collapsed".
- icon (default — the pre-existing behavior): shrinks to --sidebar-width-icon,
labels hide (sr-only, stay in the accessible name).
- offcanvas: slides fully off-screen (translate) and its width drops to 0, so page
content reflows to fill the space.
- none: never collapses (and never becomes the mobile Sheet) — always renders at
--sidebar-width. SidebarTrigger/SidebarRail/toggleSidebar become no-ops for it. |
side | "left" | "right" | 'left' | Which edge the sidebar sits on. Also controls which edge the mobile Sheet slides in from. |
variant | "floating" | "inset" | "sidebar" | 'sidebar' | Visual treatment (desktop only — the mobile Sheet always uses its own panel styling).
- sidebar (default): flush rail, bordered against the page edge.
- floating: a detached panel — margin on every edge, its own border/radius/shadow.
- inset: same rail treatment as sidebar; pair it with SidebarInset on the main
content, which becomes the rounded/bordered/shadowed panel instead. |
Data attributes and CSS variables on AppShellSidebar
| Attribute | Values |
|---|---|
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. |
Data attributes and CSS variables on AppShellHeader
| Attribute | Values |
|---|---|
data-slot | "app-shell-header" | "app-shell-header-actions" | "app-shell-header-middle" |
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 |
AppShellSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
navItemCount | number | 5 | Number of nav-row placeholders (SidebarMenuSkeleton) in the sidebar column. |
statCardCount | number | 4 | Number of stat-card placeholders (Skeleton shape="card") in the content region. |
Data attributes and CSS variables on AppShellSkeleton
| Attribute | Values |
|---|---|
data-slot | "app-shell-skeleton" |
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). - 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 — customizable/disableable via keyboardShortcut. |
| Contract | States tested |
|---|---|
| Behaviour | default, loading, open |
| Accessibility | busy, focus-visible, labeled, semantic-html |
| Visual | default, focus, loading |
Do / Don't
Resizable
Draggable, keyboard-resizable split panes — horizontal or vertical, nestable, with an optional collapsible panel. Built on react-resizable-panels.
Settings Row
A borders-only settings layout — titled sections, bordered cards, and label-plus-control rows for building account, workspace, and preference screens.