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.
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:
| Token | Where the shell uses it |
|---|---|
| background / foreground | Base surface and text: light-theme header rows, sidebar panels, footer bars. ScrollNav inverts them for the active pill. |
| primary / primary-foreground | theme="primary" header rows, the active tab tint and indicator in the footer, and HeaderNav item states on colored headers. |
| muted / muted-foreground | Inactive labels, subdued text, and idle ScrollNav pills. |
| accent / accent-foreground | Hover and active states on nav items, groups, and menu buttons. |
| popover | The HeaderNav dropdown panel background. The panel sets no text color of its own, so also define popover-foreground for content you render inside. |
| destructive | The FooterItem badge bubble. (Badge text is fixed white; destructive-foreground is part of the standard set but not read by the shell today.) |
| border | Every row divider, panel edge, and drawer border. |
| ring | Focus-visible rings on nav items, pills, and toggles. |
Re-branding is just changing values — no component props involved:
/* 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.
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.
Custom content rendered inside header rows can read the active theme with useHeaderTheme() and adapt its colors — the same mechanism HeaderNav items use:
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:
/* --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);
}