Skip to content

Getting started

Theming

The shell has no colors of its own — every surface resolves through shadcn/ui-style design tokens, so one palette themes your app and the shell together, and dark mode is a class flip.

How the tokens work

Components style themselves with Tailwind utilities like bg-background and border-border. Those utilities exist because your stylesheet defines CSS custom properties (--background, --border, …) and maps them to Tailwind v4 colors with an @theme inline block — the same convention shadcn/ui uses, so an existing shadcn palette works unchanged. The full setup lives in Installation.

Token values must be complete CSS colors — oklch(1 0 0), hsl(220 15% 97%), hex, anything the browser can paint. Bare HSL triples like 0 0% 100% (the Tailwind v3 / early-shadcn convention) are not valid colors under the v4 @theme inline mapping and render as no color at all.

Token reference

These are the tokens the library’s components actually consume. Define the whole set (plus popover-foreground and destructive-foreground, which your own content inside dropdowns and badges will want) and map each one in @theme inline:

TokenWhere the shell uses it
background / foregroundBase surface and text: light-theme header rows, sidebar panels, footer bars. ScrollNav inverts them for the active pill.
primary / primary-foregroundtheme="primary" header rows, the active tab tint and indicator in the footer, and HeaderNav item states on colored headers.
muted / muted-foregroundInactive labels, subdued text, and idle ScrollNav pills.
accent / accent-foregroundHover and active states on nav items, groups, and menu buttons.
popoverThe HeaderNav dropdown panel background. The panel sets no text color of its own, so also define popover-foreground for content you render inside.
destructiveThe FooterItem badge bubble. (Badge text is fixed white; destructive-foreground is part of the standard set but not read by the shell today.)
borderEvery row divider, panel edge, and drawer border.
ringFocus-visible rings on nav items, pills, and toggles.

Re-branding is just changing values — no component props involved:

globals.css
/* Re-brand by changing token values — the components pick
   them up through the @theme inline mapping (see Installation). */
:root {
  --primary: oklch(0.55 0.2 260);
  --primary-foreground: oklch(0.98 0.01 260);
  --ring: oklch(0.55 0.2 260);
}

.dark {
  --background: oklch(0.16 0.02 260);
  --foreground: oklch(0.97 0.01 260);
  --primary: oklch(0.7 0.16 260);
  --primary-foreground: oklch(0.16 0.02 260);
}

Dark mode

The library ships zero dark: utilities — it re-themes entirely through the tokens. Redefine them under a .dark class and everything (headers, drawers, tab bars, pills) flips at once. For your own dark: utilities to follow that same class instead of the OS setting, your stylesheet must declare @custom-variant dark (&:is(.dark *)) — it is part of the Installation setup.

Open fullscreen
9:41
app.tsx
import { AppShell, Header, Content } from "appshell-react";
import { useState } from "react";
import { Moon, Sun } from "lucide-react";

export default function App() {
  const [dark, setDark] = useState(false);

  return (
    <div className={dark ? "dark" : undefined}>
      <AppShell safeArea>
        <Header
          behavior="fixed"
          logo={<span className="font-bold">Nocturne</span>}
          actions={
            <button
              aria-label="Toggle dark mode"
              onClick={() => setDark((d) => !d)}
            >
              {dark ? <Sun className="size-5" /> : <Moon className="size-5" />}
            </button>
          }
        />
        <Content className="p-4">{/* re-themes instantly via tokens */}</Content>
      </AppShell>
    </div>
  );
}

Header themes

Independently of your palette, the Header takes a theme prop for all of its rows: "light" follows your background/foreground tokens, "primary" paints the rows with your primary tokens, "dark" is a fixed near-black (zinc) palette that stays dark regardless of tokens, and "none" ships zero styles so you can bring your own via className.

Open fullscreen
9:41

Custom content rendered inside header rows can read the active theme with useHeaderTheme() and adapt its colors — the same mechanism HeaderNav items use:

app.tsx
import { Header, useHeaderTheme } from "appshell-react";

/** Custom row content that adapts to the active header theme. */
function PlanBadge() {
  const theme = useHeaderTheme(); // "light" | "primary" | "dark" | "none"
  const onColor = theme === "primary" || theme === "dark";

  return (
    <span
      className={
        onColor
          ? "text-primary-foreground/80 text-xs font-medium"
          : "text-muted-foreground text-xs font-medium"
      }
    >
      Pro plan
    </span>
  );
}

export default function App() {
  return (
    <Header
      theme="primary"
      logo={<span className="font-bold">MyApp</span>}
      actions={<PlanBadge />}
    />
  );
}

CSS variables: read vs. written

Safe-area insets come straight from the platform standard env(safe-area-inset-top/bottom/left/right) — the values iOS and Android populate once your viewport meta tag carries viewport-fit=cover. The library reads them everywhere it pads an edge and never sets them. For environments where real insets are zero (mockups, Storybook, tests), defining --appshell-safe-area-inset-top (/-bottom/-left/-right) overrides the corresponding env() value — a simulation hook, nothing more.

It writes a handful of variables on the root element, each one owned by the component that measures it: --header-height (Header, via a ResizeObserver, whenever the header resizes), --appshell-footer-height (Footer, the same way, for Content’s automatic bottom margin), --appshell-keyboard-inset-bottom (published only while something calls useKeyboardInset(), from visualViewport), and --appshell-scrollbar-gap (the overlay stack, only while a modal overlay holds the scroll lock). The one you will reach for directly is --header-height — use it to dock sticky sub-navigation or a docked sidebar below the header:

styles.css
/* --header-height is written by <Header> (in px, via a
   ResizeObserver). Read it — never author it yourself. */
.sub-nav {
  position: sticky;
  top: var(--header-height, 0px);
}
Do not author any of these statically in your stylesheet — each one is overwritten by its owning component as soon as it mounts, and kept in sync as things change (rows wrap, the footer variant switches, the keyboard opens). See the sticky sub-navigation example for it in action.