Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
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

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

The 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 — data-slot="app-shell" | "app-shell-skip-link"
AppShellContent — data-slot="app-shell-content"
AppShellHeader — data-slot="app-shell-header" | "app-shell-header-actions" | "app-shell-header-middle"
AppShellSidebar — data-slot="app-shell-sidebar"
AppShellSkeleton — data-slot="app-shell-skeleton"
<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"). Wraps SidebarProvider, forwarding defaultOpen/open/onOpenChange/mobileBreakpoint/keyboardShortcut. Renders the skip link.
  • AppShellSidebar — a thin wrapper over Sidebar (data-slot="app-shell-sidebar") that defaults aria-label="Main navigation" and passes variant/collapsible/side straight through.
  • 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.
  • 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.
  • 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).

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.

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.

Skip to content
Mobile dashboard

Use the menu trigger to open navigation over this content.

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

PropTypeDefaultDescription
contentIdstringThe 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 | booleantrueKeyboard shortcut that toggles the sidebar — forwarded to SidebarProvider. true uses Cmd/Ctrl+B, pass a key string to customize, or false to disable.
mobileBreakpointnumber768Viewport 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.
openbooleanControlled 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-slot"app-shell" | "app-shell-skip-link"

AppShellSidebar

PropTypeDefaultDescription
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

AttributeValues
data-slot"app-shell-sidebar"

AppShellHeader

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

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

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

AppShellSkeleton

PropTypeDefaultDescription
navItemCountnumber5Number of nav-row placeholders (SidebarMenuSkeleton) in the sidebar column.
statCardCountnumber4Number of stat-card placeholders (Skeleton shape="card") in the content region.

Data attributes and CSS variables on AppShellSkeleton

AttributeValues
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'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).
  • 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 — customizable/disableable via keyboardShortcut.
ContractStates tested
Behaviourdefault, loading, open
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