Skip to main content

AppShell

Templatebeta@macaw-ds/ui · since 0.1.0

The application frame: skip link, sticky TopBar, full-height SideNav column and the main area. One per app, at the root layout.

Import

import { AppShell } from '@macaw-ds/ui';

Props

PropTypeDefaultDescription
topBar*ReactNode—

The TopBar.

sideNavReactNode—

The SideNav. Omit for pages without primary navigation.

sideNavLabelstringPrimary

Accessible name for the side column landmark.

mainIdstringmain

id of the main element; the skip link targets it.

mainLabelstring—

Accessible name for main. Only when several apps share one landmark list, such as an app embedded in an iframe.

skipLinkLabelstringSkip to main content

Text of the skip link.

childrenReactNode—

The page: usually PageHeader followed by content.

Slots

Named parts you pass content into.

  • topBar
  • sideNav
  • children

Tokens used

Every value this component renders comes from one of these tokens.

TokenLightDark
color.surface.canvas

semantic · color

oklch(0.985 0.002 250)
oklch(0.11 0.01 250)
color.surface.layer-1

semantic · color

oklch(1 0 0)
oklch(0.15 0.012 250)
color.line.default

semantic · color

oklch(0.925 0.005 250)
oklch(0.29 0.012 250)
size.topbar

semantic · dimension

56px
56px
size.sidenav

semantic · dimension

240px
240px
z.sticky

primitive · number

100
100
color.focus.ring

semantic · color

oklch(0.51 0.2 262)
oklch(0.68 0.16 262)

Eject

Ejecting copies the source into your repo and records it in .macaw/ejected.json, so the fork stays visible. The same file is served as a shadcn-compatible registry item.

With the MACAW CLI
npx @macaw-ds/cli add app-shell --eject
With shadcn
npx shadcn@latest add https://macaw-design-system.vercel.app/r/app-shell.json

Bundle budget: 1,500 bytes gzipped, enforced by size-limit in CI.

Manifest

registry/manifests/app-shell.json, the contract the playground, CLI, plugin and MCP server read.

{
  "name": "AppShell",
  "slug": "app-shell",
  "package": "@macaw-ds/ui",
  "status": "beta",
  "since": "0.1.0",
  "level": "template",
  "category": "layout",
  "purpose": "The application frame: skip link, sticky TopBar, full-height SideNav column and the main area. One per app, at the root layout.",
  "description": "Lays out the product chrome with depth by tone: the canvas is the background, the TopBar and SideNav column sit on layer-1. The SideNav column is sticky, exactly the viewport height below the TopBar, and scrolls on its own; the window scrolls the main area. A skip link is the first Tab stop.",
  "exports": [
    "AppShell"
  ],
  "props": {
    "topBar": {
      "type": "node",
      "required": true,
      "description": "The TopBar.",
      "control": "none"
    },
    "sideNav": {
      "type": "node",
      "required": false,
      "description": "The SideNav. Omit for pages without primary navigation.",
      "control": "none"
    },
    "sideNavLabel": {
      "type": "string",
      "default": "Primary",
      "required": false,
      "description": "Accessible name for the side column landmark.",
      "control": "none"
    },
    "mainId": {
      "type": "string",
      "default": "main",
      "required": false,
      "description": "id of the main element; the skip link targets it.",
      "control": "none"
    },
    "mainLabel": {
      "type": "string",
      "required": false,
      "description": "Accessible name for main. Only when several apps share one landmark list, such as an app embedded in an iframe.",
      "control": "none"
    },
    "skipLinkLabel": {
      "type": "string",
      "default": "Skip to main content",
      "required": false,
      "description": "Text of the skip link.",
      "control": "none"
    },
    "children": {
      "type": "node",
      "required": false,
      "description": "The page: usually PageHeader followed by content.",
      "control": "none"
    }
  },
  "slots": [
    "topBar",
    "sideNav",
    "children"
  ],
  "tokens": [
    "color.surface.canvas",
    "color.surface.layer-1",
    "color.line.default",
    "size.topbar",
    "size.sidenav",
    "z.sticky",
    "color.focus.ring"
  ],
  "a11y": {
    "role": "Provides the landmarks: banner (TopBar), complementary (side column, named), main",
    "focusable": true,
    "keyboard": [
      "Tab: the first stop is the skip link; Enter moves focus to main"
    ],
    "name": "The side column is named by sideNavLabel; main and banner are unique per page.",
    "notes": [
      "The skip link satisfies WCAG 2.4.1 Bypass Blocks; it is visually hidden until focused.",
      "main has tabIndex=-1 so the skip link can move focus to it.",
      "Sticky chrome is offset with size.topbar so focused content is never obscured (WCAG 2.2 SC 2.4.11).",
      "Below 1024px nothing is sticky and the side column stacks above main, so the page reflows at 320px wide (WCAG 1.4.10). At desktop widths, scroll padding keeps focused elements clear of the sticky chrome (WCAG 2.4.11).",
      "Screen readers list an iframe's landmarks with the host page's; an embedded app should name its banner (TopBar aria-label) and main (mainLabel) so landmarks stay unique."
    ]
  },
  "budget": {
    "gzip": 1500
  },
  "rules": [
    "One AppShell per application, at the root layout.",
    "Put a TopBar in topBar and a SideNav in sideNav; do not build the frame by hand.",
    "Start each page with a PageHeader, then the content in a px-margin py-section column."
  ],
  "dos": [
    "Set data-density=\"compact\" on html (or the AppShell) for data-dense products; the frame adapts."
  ],
  "donts": [
    "Do not add a second scroll container around the page; the window scrolls main.",
    "Do not give the main area its own background; it sits on the canvas."
  ],
  "examples": [
    {
      "name": "Default",
      "props": {}
    }
  ],
  "registryDependencies": [
    "top-bar",
    "side-nav"
  ],
  "dependencies": [],
  "related": [
    "top-bar",
    "side-nav",
    "page-header"
  ]
}