Shell
The frame of an app: header, navigation and main area, reshaping itself from phone to desktop.
SourcePackage @kerf-js/veneer
Preview
import { state } from '@kerf-js/core'import { Badge, Button, Card, Grid, Heading, Row, Shell, Stack, Text} from '@kerf-js/veneer'import type { IconName, ShellProps} from '@kerf-js/veneer'export function App() { const page = state('projects') const nav = kerfNav((id) => (page.value = id)) return ( <AppWindow height={600}> <Shell island label="kerf" brand={{ name: 'kerf', mark: <MARK /> }} title={pageOf[page.value]!} crumbs={[ { label: 'Acme', href: '#' }, { label: pageOf[page.value]! }, ]} nav={nav} foot={[ { id: 'settings', label: 'Settings', icon: 'settings', onSelect: () => (page.value = 'settings'), }, ]} current={page.value} actions={ <Button size="sm" icon="rocket" > Deploy </Button> } account={ACCOUNT} > <Heading level={1} role="heading" > {pageOf[page.value]!} </Heading> <Grid min="sm" gap="related" > {PROJECTS.slice(0, 6).map((p) => ( <Card key={p.id}> <Stack gap="hair"> <Row justify="between" gap="tight" > <Text role="label">{p.name}</Text> <Badge tone={TONE_OF[p.status]} dot > {p.status} </Badge> </Row> <Text role="caption" subtle > {p.region} · {p.latency} ms </Text> </Stack> </Card> ))} </Grid> </Shell> </AppWindow> )}const PEOPLE = [ { id: 'mara', name: 'Mara Lindqvist', email: 'mara@kerf.dev', role: 'Owner', team: 'Platform', initials: 'ML', }, { id: 'jun', name: 'Jun Tanaka', email: 'jun@kerf.dev', role: 'Admin', team: 'Platform', initials: 'JT', }, { id: 'priya', name: 'Priya Raman', email: 'priya@kerf.dev', role: 'Member', team: 'Web', initials: 'PR', }, { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@kerf.dev', role: 'Member', team: 'Web', initials: 'TO', }, { id: 'lena', name: 'Lena Fischer', email: 'lena@kerf.dev', role: 'Member', team: 'Data', initials: 'LF', }, { id: 'noor', name: 'Noor Haddad', email: 'noor@kerf.dev', role: 'Viewer', team: 'Data', initials: 'NH', }, { id: 'sam', name: 'Sam Okafor', email: 'sam@kerf.dev', role: 'Member', team: 'Mobile', initials: 'SO', }, { id: 'ines', name: 'Inés Vidal', email: 'ines@kerf.dev', role: 'Viewer', team: 'Mobile', initials: 'IV', },]type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const TONE_OF: Record< Status, 'positive' | 'info' | 'critical' | 'neutral'> = { Live: 'positive', Building: 'info', Failed: 'critical', Paused: 'neutral',}const min = 60_000function AppWindow({ height, wide, children,}: { height: number wide?: number children?: unknown}) { const box = () => `--veneer-viewport-block:${height}px;` + `block-size:${height}px;` + (wide ? `inline-size:min(100%,${wide}px);` + 'margin-inline:auto;' : '') + 'overflow:auto;contain:layout paint;' + 'border:var(--veneer-border-hairline) solid ' + 'var(--veneer-color-border);' + 'border-radius:var(--veneer-radius-surface)' return <div style={box()}>{children}</div>}const MARK = () => ( <svg viewBox="0 0 24 24" aria-hidden="true" > <rect x="3" y="3" width="8" height="8" rx="2" fill="var(--veneer-color-accent-solid)" /> <rect x="13" y="13" width="8" height="8" rx="2" fill="var(--veneer-color-accent-solid)" /> </svg>)const ME = PEOPLE[0]!const ACCOUNT: ShellProps['account'] = { name: ME.name, email: ME.email, initials: ME.initials, items: [ { label: 'Profile', icon: 'user' }, { label: 'Preferences', icon: 'settings' }, 'separator', { label: 'Sign out', icon: 'log-out', tone: 'critical', }, ],}function kerfNav(go: (id: string) => void) { const item = ( id: string, label: string, { icon, count }: { icon: IconName; count?: number }, ) => ({ id, label, icon, count, onSelect: () => go(id), }) return [ { label: 'Workspace', items: [ item('projects', 'Projects', { icon: 'layers' }), item('deploys', 'Deploys', { icon: 'rocket', count: 2, }), item('activity', 'Activity', { icon: 'activity', }), item('team', 'Team', { icon: 'users' }), item('billing', 'Billing', { icon: 'folder' }), ], }, { label: 'Saved views', items: [ item('failed', 'Failed builds', { icon: 'filter', count: 1, }), ], }, ]}const pageOf: Record<string, string> = { projects: 'Projects', deploys: 'Deploys', activity: 'Activity', team: 'Team', billing: 'Billing', failed: 'Failed builds', settings: 'Settings',}Installation
npm install @kerf-js/veneerUsage
import { Shell } from '@kerf-js/veneer'Collapsed to a rail
Bind collapsed to a state to keep it. Press [, or the toggle at the foot of the rail.
import { state } from '@kerf-js/core'import { Heading, Shell, Text } from '@kerf-js/veneer'import type { IconName, ShellProps} from '@kerf-js/veneer'export function Rail() { const page = state('deploys') const folded = state<boolean | 'hidden'>(true) const nav = kerfNav((id) => (page.value = id)) return ( <AppWindow height={420}> <Shell island label="kerf" brand={{ name: 'kerf', mark: <MARK /> }} crumbs={[{ label: pageOf[page.value]! }]} nav={nav} current={page.value} collapsed={folded} account={ACCOUNT} > <Heading level={1} role="heading" > {pageOf[page.value]!} </Heading> <Text subtle> The rail keeps icons and short labels. Its toggle opens the full sidebar. </Text> </Shell> </AppWindow> )}const PEOPLE = [ { id: 'mara', name: 'Mara Lindqvist', email: 'mara@kerf.dev', role: 'Owner', team: 'Platform', initials: 'ML', }, { id: 'jun', name: 'Jun Tanaka', email: 'jun@kerf.dev', role: 'Admin', team: 'Platform', initials: 'JT', }, { id: 'priya', name: 'Priya Raman', email: 'priya@kerf.dev', role: 'Member', team: 'Web', initials: 'PR', }, { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@kerf.dev', role: 'Member', team: 'Web', initials: 'TO', }, { id: 'lena', name: 'Lena Fischer', email: 'lena@kerf.dev', role: 'Member', team: 'Data', initials: 'LF', }, { id: 'noor', name: 'Noor Haddad', email: 'noor@kerf.dev', role: 'Viewer', team: 'Data', initials: 'NH', }, { id: 'sam', name: 'Sam Okafor', email: 'sam@kerf.dev', role: 'Member', team: 'Mobile', initials: 'SO', }, { id: 'ines', name: 'Inés Vidal', email: 'ines@kerf.dev', role: 'Viewer', team: 'Mobile', initials: 'IV', },]const min = 60_000function AppWindow({ height, wide, children,}: { height: number wide?: number children?: unknown}) { const box = () => `--veneer-viewport-block:${height}px;` + `block-size:${height}px;` + (wide ? `inline-size:min(100%,${wide}px);` + 'margin-inline:auto;' : '') + 'overflow:auto;contain:layout paint;' + 'border:var(--veneer-border-hairline) solid ' + 'var(--veneer-color-border);' + 'border-radius:var(--veneer-radius-surface)' return <div style={box()}>{children}</div>}const MARK = () => ( <svg viewBox="0 0 24 24" aria-hidden="true" > <rect x="3" y="3" width="8" height="8" rx="2" fill="var(--veneer-color-accent-solid)" /> <rect x="13" y="13" width="8" height="8" rx="2" fill="var(--veneer-color-accent-solid)" /> </svg>)const ME = PEOPLE[0]!const ACCOUNT: ShellProps['account'] = { name: ME.name, email: ME.email, initials: ME.initials, items: [ { label: 'Profile', icon: 'user' }, { label: 'Preferences', icon: 'settings' }, 'separator', { label: 'Sign out', icon: 'log-out', tone: 'critical', }, ],}function kerfNav(go: (id: string) => void) { const item = ( id: string, label: string, { icon, count }: { icon: IconName; count?: number }, ) => ({ id, label, icon, count, onSelect: () => go(id), }) return [ { label: 'Workspace', items: [ item('projects', 'Projects', { icon: 'layers' }), item('deploys', 'Deploys', { icon: 'rocket', count: 2, }), item('activity', 'Activity', { icon: 'activity', }), item('team', 'Team', { icon: 'users' }), item('billing', 'Billing', { icon: 'folder' }), ], }, { label: 'Saved views', items: [ item('failed', 'Failed builds', { icon: 'filter', count: 1, }), ], }, ]}const pageOf: Record<string, string> = { projects: 'Projects', deploys: 'Deploys', activity: 'Activity', team: 'Team', billing: 'Billing', failed: 'Failed builds', settings: 'Settings',}Focus
variant="focus" has no navigation: Close, a title and the step, with dock for the main action.
import { state } from '@kerf-js/core'import { Button, Heading, Input, Shell} from '@kerf-js/veneer'export function Focus() { const step = state(1) const next = () => (step.value = step.value === 3 ? 1 : step.value + 1) return ( <AppWindow height={420}> <Shell island label="New project" variant="focus" title="New project" step={`Step ${step.value} of 3`} close={{ label: 'Close', onClick: () => (step.value = 1), }} dock={ <Button onClick={next}> {step.value === 3 ? 'Create project' : 'Continue'} </Button> } > <Heading level={1} role="heading" > {FOCUS_STEPS[step.value - 1]} </Heading> <Input label="Repository" defaultValue="kerf/storefront" /> </Shell> </AppWindow> )}const min = 60_000function AppWindow({ height, wide, children,}: { height: number wide?: number children?: unknown}) { const box = () => `--veneer-viewport-block:${height}px;` + `block-size:${height}px;` + (wide ? `inline-size:min(100%,${wide}px);` + 'margin-inline:auto;' : '') + 'overflow:auto;contain:layout paint;' + 'border:var(--veneer-border-hairline) solid ' + 'var(--veneer-color-border);' + 'border-radius:var(--veneer-radius-surface)' return <div style={box()}>{children}</div>}const FOCUS_STEPS = [ 'Connect a repository', 'Choose a region', 'Check your settings',]Phone
Bottom tabs
The first four items become tabs. The rest move into More, or mark your own with tab.
import { state } from '@kerf-js/core'import { Badge, Heading, Row, Shell, Stack, Text} from '@kerf-js/veneer'import type { IconName, ShellProps} from '@kerf-js/veneer'export function Phone() { const page = state('projects') const nav = kerfNav((id) => (page.value = id)) return ( <AppWindow height={560} wide={375} > <Shell island label="kerf" brand={{ name: 'kerf', mark: <MARK /> }} title={pageOf[page.value]!} nav={nav} current={page.value} account={ACCOUNT} > <Heading level={1} role="heading" > {pageOf[page.value]!} </Heading> <Stack divided gap="related" > {PROJECTS.slice(0, 5).map((p) => ( <Row key={p.id} justify="between" gap="tight" > <Text>{p.name}</Text> <Badge tone={TONE_OF[p.status]} dot > {p.status} </Badge> </Row> ))} </Stack> </Shell> </AppWindow> )}const PEOPLE = [ { id: 'mara', name: 'Mara Lindqvist', email: 'mara@kerf.dev', role: 'Owner', team: 'Platform', initials: 'ML', }, { id: 'jun', name: 'Jun Tanaka', email: 'jun@kerf.dev', role: 'Admin', team: 'Platform', initials: 'JT', }, { id: 'priya', name: 'Priya Raman', email: 'priya@kerf.dev', role: 'Member', team: 'Web', initials: 'PR', }, { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@kerf.dev', role: 'Member', team: 'Web', initials: 'TO', }, { id: 'lena', name: 'Lena Fischer', email: 'lena@kerf.dev', role: 'Member', team: 'Data', initials: 'LF', }, { id: 'noor', name: 'Noor Haddad', email: 'noor@kerf.dev', role: 'Viewer', team: 'Data', initials: 'NH', }, { id: 'sam', name: 'Sam Okafor', email: 'sam@kerf.dev', role: 'Member', team: 'Mobile', initials: 'SO', }, { id: 'ines', name: 'Inés Vidal', email: 'ines@kerf.dev', role: 'Viewer', team: 'Mobile', initials: 'IV', },]type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const TONE_OF: Record< Status, 'positive' | 'info' | 'critical' | 'neutral'> = { Live: 'positive', Building: 'info', Failed: 'critical', Paused: 'neutral',}const min = 60_000function AppWindow({ height, wide, children,}: { height: number wide?: number children?: unknown}) { const box = () => `--veneer-viewport-block:${height}px;` + `block-size:${height}px;` + (wide ? `inline-size:min(100%,${wide}px);` + 'margin-inline:auto;' : '') + 'overflow:auto;contain:layout paint;' + 'border:var(--veneer-border-hairline) solid ' + 'var(--veneer-color-border);' + 'border-radius:var(--veneer-radius-surface)' return <div style={box()}>{children}</div>}const MARK = () => ( <svg viewBox="0 0 24 24" aria-hidden="true" > <rect x="3" y="3" width="8" height="8" rx="2" fill="var(--veneer-color-accent-solid)" /> <rect x="13" y="13" width="8" height="8" rx="2" fill="var(--veneer-color-accent-solid)" /> </svg>)const ME = PEOPLE[0]!const ACCOUNT: ShellProps['account'] = { name: ME.name, email: ME.email, initials: ME.initials, items: [ { label: 'Profile', icon: 'user' }, { label: 'Preferences', icon: 'settings' }, 'separator', { label: 'Sign out', icon: 'log-out', tone: 'critical', }, ],}function kerfNav(go: (id: string) => void) { const item = ( id: string, label: string, { icon, count }: { icon: IconName; count?: number }, ) => ({ id, label, icon, count, onSelect: () => go(id), }) return [ { label: 'Workspace', items: [ item('projects', 'Projects', { icon: 'layers' }), item('deploys', 'Deploys', { icon: 'rocket', count: 2, }), item('activity', 'Activity', { icon: 'activity', }), item('team', 'Team', { icon: 'users' }), item('billing', 'Billing', { icon: 'folder' }), ], }, { label: 'Saved views', items: [ item('failed', 'Failed builds', { icon: 'filter', count: 1, }), ], }, ]}const pageOf: Record<string, string> = { projects: 'Projects', deploys: 'Deploys', activity: 'Activity', team: 'Team', billing: 'Billing', failed: 'Failed builds', settings: 'Settings',}A child page
depth="child" with back swaps the brand for a back button.
import { state } from '@kerf-js/core'import { Heading, Shell, Text } from '@kerf-js/veneer'import type { IconName } from '@kerf-js/veneer'export function ChildPage() { const left = state(false) return ( <AppWindow height={420} wide={375} > <Shell island label="kerf" depth="child" title="storefront" back={{ label: 'Back to projects', onClick: () => (left.value = !left.value), }} brand={{ name: 'kerf' }} nav={ROOT_NAV} current="projects" > <Heading level={1} role="heading" > storefront </Heading> <Text subtle> {left.value ? 'Back pressed. A real app would route ' + 'to the list here.' : 'On a phone, a child page swaps the ' + 'brand for a back button.'} </Text> </Shell> </AppWindow> )}const min = 60_000function AppWindow({ height, wide, children,}: { height: number wide?: number children?: unknown}) { const box = () => `--veneer-viewport-block:${height}px;` + `block-size:${height}px;` + (wide ? `inline-size:min(100%,${wide}px);` + 'margin-inline:auto;' : '') + 'overflow:auto;contain:layout paint;' + 'border:var(--veneer-border-hairline) solid ' + 'var(--veneer-color-border);' + 'border-radius:var(--veneer-radius-surface)' return <div style={box()}>{children}</div>}function kerfNav(go: (id: string) => void) { const item = ( id: string, label: string, { icon, count }: { icon: IconName; count?: number }, ) => ({ id, label, icon, count, onSelect: () => go(id), }) return [ { label: 'Workspace', items: [ item('projects', 'Projects', { icon: 'layers' }), item('deploys', 'Deploys', { icon: 'rocket', count: 2, }), item('activity', 'Activity', { icon: 'activity', }), item('team', 'Team', { icon: 'users' }), item('billing', 'Billing', { icon: 'folder' }), ], }, { label: 'Saved views', items: [ item('failed', 'Failed builds', { icon: 'filter', count: 1, }), ], }, ]}const ROOT_NAV = kerfNav(() => {})Showing a shell inside a page
A shell fills the screen by default, which is what you want in an app: put it at the top of your layout and nothing else is needed.
To preview one inside a page, as here, pass island so the page keeps its one <main>, set --veneer-viewport-block to the height you want, and give the box around it contain: layout so the shell's overlays stay inside. The AppWindow helper in these examples does exactly that.
When to use
Accessibility
- [
- Collapses or expands the sidebar
- ⌘K
- Runs `command`, when it is set
- Esc
- Closes the open navigation
- Tab
- The first stop is Skip to content
- Parts are props rather than children, so the focus order is always skip link, brand, header, navigation, main, dock.
- A change to
currentannounces the new page'sh1, so give every page one. - With
island, main becomes a named region, since a document has one<main>.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "focus" | "app" | — | app (default) · focus: a wizard or checkout, with no nav. |
brand | { name: string; href?: string | undefined; mark?: unknown; } | — | The product's name and mark. Shown in the header, and as the home link. |
title | unknown | — | The phone header's title. |
crumbs | Crumb[] | — | The path in the header from tablet up. The last is the current page. |
depth | "root" | "child" | — | child puts a back button in the brand's place on a phone, for a page under another. |
back | { label: string; href?: string | undefined; onClick?: (() => void) | und… | — | Phone, depth child: the back button in the brand's place. |
close | { label: string; href?: string | undefined; onClick?: (() => void) | und… | — | Focus variant: Close at the start and the step count at the end. |
step | unknown | — | How far one key press moves it. |
command | { label?: string | undefined; hint?: string | undefined; shortcut?: stri… | — | A search field in the header. It takes over ⌘K. |
actions | unknown | — | Your own buttons in the header. |
account | { name: string; email?: string | undefined; initials: string; items?: Me… | — | The account button and its menu. |
switcher | { current: string; workspaces: Workspace[]; onSwitch: (id: string) => vo… | — | A workspace switcher above the navigation. |
nav | NavGroup[] | — | Groups of items. The first is the main list; the rest are extras such as saved views, in the sidebar only. |
foot | NavItem[] | — | Items pinned to the bottom of the navigation. |
current | string | — | The id of the current item. A change plays the route line and announces the page. |
navLabel | string | — | Names the navigation landmark for screen readers. |
collapsed | State<boolean | "hidden"> | — | A state: false, true for the rail, or hidden for an edge tab. |
label | string | — | Names main when island, or the region around a preview. |
island | boolean | false | For a shell inside a page: main becomes a named region. |
dock | unknown | — | A sticky bar under the content, for the page's main action. |
overlays | unknown | — | Dialogs, toasts and palettes, kept outside main. |
children | unknown | — | What it contains. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="shell"] [data-part="page"]
page