Skip to content
weftGitHub

Shell

The frame of an app: header, navigation and main area, reshaping itself from phone to desktop.

SourcePackage @weft-js/warp

Preview

tsx
import { state } from '@weft-js/core'import { Badge, Button, Card, Grid, Heading, Row, Shell, Stack, Text } from '@weft-js/warp'import type { IconName, ShellProps } from '@weft-js/warp'export function App() {  const page = state('projects')  const nav = weftNav(id => (page.value = id))  return (    <AppWindow height={600}>      <Shell        island        label="weft"        brand={{ name: 'weft', 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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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 = () => `--warp-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(--warp-border-hairline) solid var(--warp-color-border);border-radius:var(--warp-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(--warp-color-accent-solid)" /><rect x="13" y="13" width="8" height="8" rx="2" fill="var(--warp-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 weftNav(go: (id: string) => void) {  const item = (id: string, label: string, icon: IconName, count?: number) => ({ id, label, icon, count, onSelect: () => go(id) })  return [    { label: 'Workspace', items: [item('projects', 'Projects', 'layers'), item('deploys', 'Deploys', 'rocket', 2), item('activity', 'Activity', 'activity'), item('team', 'Team', 'users'), item('billing', 'Billing', 'folder')] },    { label: 'Saved views', items: [item('failed', 'Failed builds', 'filter', 1)] },  ]}const pageOf: Record<string, string> = { projects: 'Projects', deploys: 'Deploys', activity: 'Activity', team: 'Team', billing: 'Billing', failed: 'Failed builds', settings: 'Settings' }

Installation

npm install @weft-js/warp

Usage

tsx
import { Shell } from '@weft-js/warp'

Collapsed to a rail

Bind collapsed to a state to keep it. Press [, or the toggle at the foot of the rail.

tsx
import { state } from '@weft-js/core'import { Heading, Shell, Text } from '@weft-js/warp'import type { IconName, ShellProps } from '@weft-js/warp'export function Rail() {  const page = state('deploys')  const folded = state<boolean | 'hidden'>(true)  const nav = weftNav(id => (page.value = id))  return (    <AppWindow height={420}>      <Shell island label="weft" brand={{ name: 'weft', 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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.dev', role: 'Viewer', team: 'Mobile', initials: 'IV' },]const min = 60_000function AppWindow({ height, wide, children }: { height: number; wide?: number; children?: unknown }) {  const box = () => `--warp-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(--warp-border-hairline) solid var(--warp-color-border);border-radius:var(--warp-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(--warp-color-accent-solid)" /><rect x="13" y="13" width="8" height="8" rx="2" fill="var(--warp-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 weftNav(go: (id: string) => void) {  const item = (id: string, label: string, icon: IconName, count?: number) => ({ id, label, icon, count, onSelect: () => go(id) })  return [    { label: 'Workspace', items: [item('projects', 'Projects', 'layers'), item('deploys', 'Deploys', 'rocket', 2), item('activity', 'Activity', 'activity'), item('team', 'Team', 'users'), item('billing', 'Billing', 'folder')] },    { label: 'Saved views', items: [item('failed', 'Failed builds', 'filter', 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.

tsx
import { state } from '@weft-js/core'import { Button, Heading, Input, Shell } from '@weft-js/warp'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="weft/storefront" />      </Shell>    </AppWindow>  )}const min = 60_000function AppWindow({ height, wide, children }: { height: number; wide?: number; children?: unknown }) {  const box = () => `--warp-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(--warp-border-hairline) solid var(--warp-color-border);border-radius:var(--warp-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.

tsx
import { state } from '@weft-js/core'import { Badge, Heading, Row, Shell, Stack, Text } from '@weft-js/warp'import type { IconName, ShellProps } from '@weft-js/warp'export function Phone() {  const page = state('projects')  const nav = weftNav(id => (page.value = id))  return (    <AppWindow height={560} wide={375}>      <Shell island label="weft" brand={{ name: 'weft', 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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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 = () => `--warp-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(--warp-border-hairline) solid var(--warp-color-border);border-radius:var(--warp-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(--warp-color-accent-solid)" /><rect x="13" y="13" width="8" height="8" rx="2" fill="var(--warp-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 weftNav(go: (id: string) => void) {  const item = (id: string, label: string, icon: IconName, count?: number) => ({ id, label, icon, count, onSelect: () => go(id) })  return [    { label: 'Workspace', items: [item('projects', 'Projects', 'layers'), item('deploys', 'Deploys', 'rocket', 2), item('activity', 'Activity', 'activity'), item('team', 'Team', 'users'), item('billing', 'Billing', 'folder')] },    { label: 'Saved views', items: [item('failed', 'Failed builds', 'filter', 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.

tsx
import { state } from '@weft-js/core'import { Heading, Shell, Text } from '@weft-js/warp'import type { IconName } from '@weft-js/warp'export function ChildPage() {  const left = state(false)  return (    <AppWindow height={420} wide={375}>      <Shell island label="weft" depth="child" title="storefront" back={{ label: 'Back to projects', onClick: () => (left.value = !left.value) }} brand={{ name: 'weft' }} 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 = () => `--warp-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(--warp-border-hairline) solid var(--warp-color-border);border-radius:var(--warp-radius-surface)`  return <div style={box()}>{children}</div>}function weftNav(go: (id: string) => void) {  const item = (id: string, label: string, icon: IconName, count?: number) => ({ id, label, icon, count, onSelect: () => go(id) })  return [    { label: 'Workspace', items: [item('projects', 'Projects', 'layers'), item('deploys', 'Deploys', 'rocket', 2), item('activity', 'Activity', 'activity'), item('team', 'Team', 'users'), item('billing', 'Billing', 'folder')] },    { label: 'Saved views', items: [item('failed', 'Failed builds', 'filter', 1)] },  ]}const ROOT_NAV = weftNav(() => {})

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

Use it for

  • The outermost layout of a signed-in app
  • A wizard or checkout: `variant="focus"` drops the navigation

Reach for something else

  • Split, when it is a sidebar inside a page
  • Cover, when the page has no navigation at all

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 current announces the new page's h1, so give every page one.
  • With island, main becomes a named region, since a document has one <main>.

API Reference

PropTypeDefaultDescription
variant"focus" | "app"'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.
titleunknown—The phone header's title.
crumbsCrumb[]—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.
stepunknown—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.
actionsunknown—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.
navNavGroup[]—Groups of items. The first is the main list; the rest are extras such as saved views, in the sidebar only.
footNavItem[]—Items pinned to the bottom of the navigation.
currentstring—The id of the current item. A change plays the route line and announces the page.
navLabelstring'Main'Names the navigation landmark for screen readers.
collapsedState<boolean | "hidden">—A state: false, true for the rail, or hidden for an edge tab.
labelstring—Names main when island, or the region around a preview.
islandbooleanfalseFor a shell inside a page: main becomes a named region.
dockunknown—A sticky bar under the content, for the page's main action.
overlaysunknown—Dialogs, toasts and palettes, kept outside main.
childrenunknown—What it contains.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="button"] [data-part="page"]

pagerootskipedge-tablayoutheaderbrandbrand-nametitleactionscommandcommand-labelcommand-hintsr-onlynavnav-groupswitcherorgnav-listnav-morenav-itemiconnav-labelnav-extranav-footnav-togglemaindockscrimmenuseparatorgroup-labelitemitem-labelindicatorsheetshortcut

RelatedSplitCoverNavMenu