Tabs
Switches between views of the same thing, one at a time.
Preview
import { state } from '@kerf-js/core'import { Tabs, Text } from '@kerf-js/veneer'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 @kerf-js/veneerTry it
Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.
import { Tabs } from '@kerf-js/veneer'<Tabs label="Project" value={tab.value} onChange={v => (tab.value = v)} options={SECTIONS}> {v => <Text>{WORDS[v]}</Text>}</Tabs>Usage
import { Tabs } from '@kerf-js/veneer'Pill
Every tab shares one panel, so it suits a filter.
import { state } from '@kerf-js/core'import { Tabs, Text } from '@kerf-js/veneer'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.
import { state } from '@kerf-js/core'import { Tabs, Text } from '@kerf-js/veneer'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.
import { state } from '@kerf-js/core'import { Tabs, Text } from '@kerf-js/veneer'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.
import { state } from '@kerf-js/core'import { Tabs, Text } from '@kerf-js/veneer'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.kerf.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.
import { state } from '@kerf-js/core'import { Badge, Card, Grid, Heading, Row, Stack, Switch, Tabs, Text} from '@kerf-js/veneer'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.
import { state } from '@kerf-js/core'import { Badge, Row, Stack, Tabs, Text} from '@kerf-js/veneer'export function FilterList() { const status = state('All') const rows = (value: string) => PROJECTS.filter( (project) => value === 'All' || project.status === value, ).slice(0, 5) return ( <Tabs label="Status" variant="pill" value={status.value} onChange={(v) => (status.value = v)} options={FILTERS} > {(v) => ( <Stack gap="tight"> {rows(v).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
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.
Theming
Restyle it app-wide from veneer() in your kerf config. Every key takes colour, spacing, size, radius, shadow, type and motion; its own keys are listed. A state nests inside: hover: { bg: 'ink' }. Text on a fill you set is contrast-checked at build. How theming works
| Key | Own keys | States |
|---|---|---|
parts.tabs | — | none |
parts.tabs.tab | radius | hover, focus, press, active, open, disabled |
parts.tabs.underline | color, width | none |
veneer({ colors: { brand: '#e8590c', ink: '#1b1b1f' }, parts: { tabs: { tab: { active: { text: 'brand' } }, underline: { color: 'brand', width: 3 } }, },})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