Tabs
Switches between views of the same thing, one at a time.
SourcePackage @weft-js/warpPlayground
Preview
tsx
import { state } from '@weft-js/core'import { Tabs, Text } from '@weft-js/warp'export function Line() { const tab = state('overview') return ( <Tabs label="Project" value={tab.value} onChange={v => (tab.value = v)} options={SECTIONS}> {v => <Text>{WORDS[v]}</Text>} </Tabs> )}const SECTIONS = [ { value: 'overview', label: 'Overview' }, { value: 'deploys', label: 'Deploys' }, { value: 'logs', label: 'Logs' }, { value: 'settings', label: 'Settings' },]const WORDS: Record<string, string> = { overview: 'storefront is live in eu-west with 412 deploys so far.', deploys: 'The last deploy took two minutes and is serving traffic.', logs: 'No errors in the last hour.', settings: 'Deploys run on every push to main.',}Installation
npm install @weft-js/warpUsage
tsx
import { Tabs } from '@weft-js/warp'Pill
Every tab shares one panel, so it suits a filter.
tsx
import { state } from '@weft-js/core'import { Tabs, Text } from '@weft-js/warp'export function Pill() { const tab = state('overview') return ( <Tabs label="Project" variant="pill" value={tab.value} onChange={v => (tab.value = v)} options={SECTIONS}> {v => <Text>{WORDS[v]}</Text>} </Tabs> )}const SECTIONS = [ { value: 'overview', label: 'Overview' }, { value: 'deploys', label: 'Deploys' }, { value: 'logs', label: 'Logs' }, { value: 'settings', label: 'Settings' },]const WORDS: Record<string, string> = { overview: 'storefront is live in eu-west with 412 deploys so far.', deploys: 'The last deploy took two minutes and is serving traffic.', logs: 'No errors in the last hour.', settings: 'Deploys run on every push to main.',}Enclosed
The panel gets a box of its own.
tsx
import { state } from '@weft-js/core'import { Tabs, Text } from '@weft-js/warp'export function Enclosed() { const tab = state('overview') return ( <Tabs label="Project" variant="enclosed" value={tab.value} onChange={v => (tab.value = v)} options={SECTIONS}> {v => <Text>{WORDS[v]}</Text>} </Tabs> )}const SECTIONS = [ { value: 'overview', label: 'Overview' }, { value: 'deploys', label: 'Deploys' }, { value: 'logs', label: 'Logs' }, { value: 'settings', label: 'Settings' },]const WORDS: Record<string, string> = { overview: 'storefront is live in eu-west with 412 deploys so far.', deploys: 'The last deploy took two minutes and is serving traffic.', logs: 'No errors in the last hour.', settings: 'Deploys run on every push to main.',}More tabs than room
The row scrolls with faded edges and keeps the selected tab in view.
tsx
import { state } from '@weft-js/core'import { Tabs, Text } from '@weft-js/warp'export function Overflow() { const tab = state('eu-west') return ( <Tabs label="Region" value={tab.value} onChange={v => (tab.value = v)} options={REGIONS}> {v => <Text subtle>Traffic served from {v}.</Text>} </Tabs> )}const REGIONS = ['eu-west', 'eu-north', 'us-east', 'us-west', 'ap-south', 'ap-east', 'sa-east', 'af-south', 'me-central'].map(r => ({ value: r, label: r }))A disabled tab
Set disabled on the option. Arrow keys skip it.
tsx
import { state } from '@weft-js/core'import { Tabs, Text } from '@weft-js/warp'export function Disabled() { const tab = state('general') return ( <Tabs label="Settings" value={tab.value} onChange={v => (tab.value = v)} options={PLANNED}> {v => <Text>{v === 'general' ? 'Name, region and owner.' : 'storefront.weft.app and two more.'}</Text>} </Tabs> )}const PLANNED = [ { value: 'general', label: 'General' }, { value: 'domains', label: 'Domains' }, { value: 'sso', label: 'Single sign-on', disabled: true },]A project page
Tabs switch between the sections of one page, with options listing the sections and value the current one.
tsx
import { state } from '@weft-js/core'import { Badge, Card, Grid, Heading, Row, Stack, Switch, Tabs, Text } from '@weft-js/warp'export function ProjectPage() { const tab = state('overview') return ( <Stack gap="group"> <Row justify="between" gap="group"> <Heading level={3}>storefront</Heading> <Badge tone="positive" dot>Live</Badge> </Row> <Tabs label="storefront" value={tab.value} onChange={v => (tab.value = v)} options={SECTIONS}> {panel} </Tabs> </Stack> )}const DEPLOYS = [ { id: 'd-4821', project: 'storefront', branch: 'main', commit: '9f3ac21', message: 'Cache product images at the edge', author: 'Priya Raman', status: 'Live', minutes: 2, at: '2026-10-31T16:42' }, { id: 'd-4820', project: 'search-api', branch: 'ranking-v2', commit: '1be07d4', message: 'Rank exact matches first', author: 'Lena Fischer', status: 'Building', minutes: 6, at: '2026-10-31T16:10' }, { id: 'd-4819', project: 'mobile-bff', branch: 'main', commit: 'c44e190', message: 'Retry timed out upstream calls', author: 'Sam Okafor', status: 'Failed', minutes: 4, at: '2026-10-31T15:27' }, { id: 'd-4818', project: 'checkout', branch: 'main', commit: '70d2fa8', message: 'Add Apple Pay to the last step', author: 'Tomás Oliveira', status: 'Live', minutes: 3, at: '2026-10-31T14:03' }, { id: 'd-4817', project: 'edge-cache', branch: 'main', commit: 'e81b6c2', message: 'Lower the stale window to 30s', author: 'Jun Tanaka', status: 'Live', minutes: 1, at: '2026-10-31T11:55' }, { id: 'd-4816', project: 'billing', branch: 'main', commit: '2ad9e03', message: 'Prorate seats on plan change', author: 'Mara Lindqvist', status: 'Live', minutes: 3, at: '2026-10-29T17:20' },]const min = 60_000const SECTIONS = [ { value: 'overview', label: 'Overview' }, { value: 'deploys', label: 'Deploys' }, { value: 'logs', label: 'Logs' }, { value: 'settings', label: 'Settings' },]const panel = (view: string) => { if (view === 'overview') return ( <Grid min="xs" gap="related"> <Card eyebrow="Uptime" title="99.98%" /> <Card eyebrow="Latency" title="84 ms" /> <Card eyebrow="This month" title="$1,240" /> </Grid> ) if (view === 'deploys') return ( <Stack gap="related"> {DEPLOYS.slice(0, 3).map(d => ( <Row key={d.id} justify="between" gap="group"> <Text>{d.message}</Text> <Text role="code" subtle>{d.commit}</Text> </Row> ))} </Stack> ) if (view === 'logs') return <Text role="code" subtle>16:42 Deployed 9f3ac21 in 2m 04s</Text> return <Switch defaultChecked>Deploy every push to main</Switch>}Filtering a list
Use variant set to pill for a compact set of filters above a list.
tsx
import { state } from '@weft-js/core'import { Badge, Row, Stack, Tabs, Text } from '@weft-js/warp'export function FilterList() { const status = state('All') return ( <Tabs label="Status" variant="pill" value={status.value} onChange={v => (status.value = v)} options={FILTERS}> {v => ( <Stack gap="tight"> {PROJECTS.filter(p => v === 'All' || p.status === v).slice(0, 5).map(p => ( <Row key={p.id} justify="between"> <Text>{p.name}</Text> <Badge tone={TONE_OF[p.status]} dot>{p.status}</Badge> </Row> ))} </Stack> )} </Tabs> )}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 FILTERS = [ { value: 'All', label: 'All' }, { value: 'Live', label: 'Live' }, { value: 'Building', label: 'Building' }, { value: 'Failed', label: 'Failed' },]When to use
Use it for
- Three to seven views of one thing, where people only need one at a time
- Filtering one list by a single value, with the pill variant
Playground
Variant
import { Tabs } from '@weft-js/warp'<Tabs label="Project" value={tab.value} onChange={v => (tab.value = v)} options={SECTIONS}> {v => <Text>{WORDS[v]}</Text>}</Tabs>Accessibility
- ←→
- Moves to the next tab and selects it
- HomeEnd
- Jumps to the first or last tab
- Tab
- Moves into the panel
- Only the selected tab is in the tab order, and each tab points at its panel.
- In the
pillvariant every tab controls the one shared panel.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The name screen readers give the row of tabs. |
value | V | — | The selected tab. You own this value. |
options | TabOption<V>[] | — | The tabs: value, label and optionally disabled. |
variant | "pill" | "line" | "enclosed" | — | Which form it takes. |
children | ((value: V) => unknown) | — | A function from a tab's value to its panel. |
| Event | Type | Default | Description |
|---|---|---|---|
onChange | (v: V) => void | — | Called with the tab that was picked. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="tabs"] [data-part="root"]
rootlisttriggerindicatorcontent