Skip to content
weftGitHub

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

Usage

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

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

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 pill variant every tab controls the one shared panel.

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