Skip to content
kerfjsGitHub

Tabs

Switches between views of the same thing, one at a time.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
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/veneer

Try it

Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.

Live preview
Controls
Variant
Codeupdates as you change the controls
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

tsx
import { Tabs } from '@kerf-js/veneer'

Pill

Every tab shares one panel, so it suits a filter.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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

Reach for something else

  • Nav, when each view is its own page with its own URL
  • Segmented, when it is a form value, with no panel under it
  • Accordion, when people may want several sections open

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 pill variant 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

KeyOwn keysStates
parts.tabs—none
parts.tabs.tabradiushover, focus, press, active, open, disabled
parts.tabs.underlinecolor, widthnone
ts
veneer({  colors: { brand: '#e8590c', ink: '#1b1b1f' },  parts: {    tabs: { tab: { active: { text: 'brand' } }, underline: { color: 'brand', width: 3 } },  },})

API Reference

PropTypeDefaultDescription
labelstring—The name screen readers give the row of tabs.
valueV—The selected tab. You own this value.
optionsTabOption<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.
EventTypeDefaultDescription
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

RelatedNavSegmentedAccordion