Qure UI
Components

Tabs

Switches between views within a panel, where only one view is needed at a time.

14 series, 1,204 images.
import { Tabs, TabsList, TabsPanel, TabsTab } from '@/registry/qure/ui/tabs'export default function TabsDemo() {  return (    <Tabs defaultValue="images" className="w-full max-w-md">      <TabsList>        <TabsTab value="images">Images</TabsTab>        <TabsTab value="report">Report</TabsTab>        <TabsTab value="history">History</TabsTab>      </TabsList>      <TabsPanel value="images" className="text-sm text-muted-foreground">14 series, 1,204 images.</TabsPanel>      <TabsPanel value="report" className="text-sm text-muted-foreground">Draft, last saved 09:14.</TabsPanel>      <TabsPanel value="history" className="text-sm text-muted-foreground">3 prior studies.</TabsPanel>    </Tabs>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/tabs.json

Usage

import { Tabs, TabsList, TabsTab, TabsPanel } from '@/components/ui/tabs'
<Tabs defaultValue="images">
  <TabsList>
    <TabsTab value="images">Images</TabsTab>
    <TabsTab value="report">Report</TabsTab>
  </TabsList>
  <TabsPanel value="images">…</TabsPanel>
  <TabsPanel value="report">…</TabsPanel>
</Tabs>

Tabs are for alternatives — views of one thing, only one of which is wanted at a time. They are the wrong choice when the reader has to compare two panels, when the panels are steps of a sequence, or when there are enough of them to wrap onto a second row. Content that needs to be scanned together belongs in an Accordion or simply stacked; a sequence wants a stepper; a long list of destinations wants navigation, not tabs.

Nothing hidden behind a tab is discoverable by find-in-page, so never put the one number a reader came for on the second tab.

Composition

<Tabs>              {/* owns the value and the orientation */}
  <TabsList>        {/* the tab strip; carries the variant */}
    <TabsTab>       {/* one tab, keyed by value */}
  </TabsList>
  <TabsPanel>       {/* the content for a value */}
</Tabs>

Tabs is a flex column that becomes a flex row under orientation="vertical", which is what puts a side list beside its panel with no layout of your own. Panel and tab are paired by value; Base UI wires aria-controls and aria-labelledby between them.

Underline

Draft, last saved 09:14. Two findings recorded, neither signed off.
import { FileTextIcon, HistoryIcon, ImageIcon, LockIcon } from 'lucide-react'import { Tabs, TabsList, TabsPanel, TabsTab } from '@/registry/qure/ui/tabs'/** * The default variant, with an icon per tab and one tab disabled. Icons sit * before the label rather than replacing it — a tab strip is the one place an * icon-only control is rarely worth it, since the labels are what tell the * reader what the page contains before they commit to a click. */export default function TabsUnderline() {  return (    <Tabs defaultValue="report" className="w-full max-w-lg">      <TabsList>        <TabsTab value="images">          <ImageIcon className="size-4" />          Images        </TabsTab>        <TabsTab value="report">          <FileTextIcon className="size-4" />          Report        </TabsTab>        <TabsTab value="history">          <HistoryIcon className="size-4" />          History        </TabsTab>        <TabsTab value="audit" disabled>          <LockIcon className="size-4" />          Audit trail        </TabsTab>      </TabsList>      <TabsPanel value="images" className="text-muted-foreground text-sm">        14 series, 1,204 images. Acquired 09:02 on the Somatom in room 3.      </TabsPanel>      <TabsPanel value="report" className="text-muted-foreground text-sm">        Draft, last saved 09:14. Two findings recorded, neither signed off.      </TabsPanel>      <TabsPanel value="history" className="text-muted-foreground text-sm">        3 prior studies, the most recent 14 months ago.      </TabsPanel>      <TabsPanel value="audit" className="text-muted-foreground text-sm">        Visible to administrators.      </TabsPanel>    </Tabs>  )}

The default, and the one to use for tabs at the top of a page or a card — the same TabComponentTop the design system shipped. The marker is inset 8px from each edge of the tab rather than spanning it, and it is a pseudo-element rather than a border so it can be.

An icon goes before the label rather than instead of it. A tab strip is the one place an icon-only control rarely pays: the labels are what tell the reader what is behind each tab before they commit to clicking it.

Pill

512 × 512, 1.25mm slices.
import { Tabs, TabsList, TabsPanel, TabsTab } from '@/registry/qure/ui/tabs'export default function TabsPill() {  return (    <Tabs defaultValue="axial" className="w-full max-w-md">      <TabsList variant="pill">        <TabsTab value="axial">Axial</TabsTab>        <TabsTab value="coronal">Coronal</TabsTab>        <TabsTab value="sagittal">Sagittal</TabsTab>      </TabsList>      <TabsPanel value="axial" className="text-muted-foreground text-sm">        512 × 512, 1.25mm slices.      </TabsPanel>      <TabsPanel value="coronal" className="text-muted-foreground text-sm">        Reformatted, 3mm slices.      </TabsPanel>      <TabsPanel value="sagittal" className="text-muted-foreground text-sm">        Reformatted, 3mm slices.      </TabsPanel>    </Tabs>  )}

A filled track for switching how one thing is displayed rather than moving between sections of it — plane, layout, units. The list is w-fit, so it stays the width of its tabs instead of stretching across the panel and reading as a toolbar.

Two or three pills that toggle a setting rather than a view are a Toggle Group, not tabs — tabs must have panels.

Side

Patient ID
PT-88213
Sex
F
Age at study
64
import { Tabs, TabsList, TabsPanel, TabsTab } from '@/registry/qure/ui/tabs'export default function TabsSide() {  return (    <Tabs defaultValue="patient" orientation="vertical" className="w-full max-w-2xl">      <TabsList variant="side">        <TabsTab value="patient">Patient</TabsTab>        <TabsTab value="study">Study</TabsTab>        <TabsTab value="series">Series</TabsTab>        <TabsTab value="equipment">Equipment</TabsTab>      </TabsList>      <TabsPanel value="patient" className="text-sm">        <dl className="grid grid-cols-[9rem_1fr] gap-y-1.5">          <dt className="text-muted-foreground">Patient ID</dt><dd>PT-88213</dd>          <dt className="text-muted-foreground">Sex</dt><dd>F</dd>          <dt className="text-muted-foreground">Age at study</dt><dd>64</dd>        </dl>      </TabsPanel>      <TabsPanel value="study" className="text-sm">        <dl className="grid grid-cols-[9rem_1fr] gap-y-1.5">          <dt className="text-muted-foreground">Accession</dt><dd>ACC-4471902</dd>          <dt className="text-muted-foreground">Description</dt><dd>CT Thorax with contrast</dd>          <dt className="text-muted-foreground">Acquired</dt><dd>12 March 2025, 09:14</dd>        </dl>      </TabsPanel>      <TabsPanel value="series" className="text-sm">        <dl className="grid grid-cols-[9rem_1fr] gap-y-1.5">          <dt className="text-muted-foreground">Series</dt><dd>14</dd>          <dt className="text-muted-foreground">Images</dt><dd>1,204</dd>          <dt className="text-muted-foreground">Slice thickness</dt><dd>1.25mm</dd>        </dl>      </TabsPanel>      <TabsPanel value="equipment" className="text-sm">        <dl className="grid grid-cols-[9rem_1fr] gap-y-1.5">          <dt className="text-muted-foreground">Manufacturer</dt><dd>Siemens</dd>          <dt className="text-muted-foreground">Station</dt><dd>CT-2</dd>          <dt className="text-muted-foreground">kVp</dt><dd>120</dd>        </dl>      </TabsPanel>    </Tabs>  )}

Vertical placement, for a long list of sections beside their content: study metadata, settings, a DICOM header browser. Pass orientation="vertical" on the root as well as variant="side" on the list — the variant is the look, the orientation is what tells Base UI that Up and Down are the arrow keys that move between tabs.

Counts in the label

23 studies waiting, oldest 41 minutes.
import { Badge } from '@/registry/qure/ui/badge'import { Tabs, TabsList, TabsPanel, TabsTab } from '@/registry/qure/ui/tabs'const worklists = [  { value: 'unread', label: 'Unread', count: 23, tone: 'urgent' as const, body: '23 studies waiting, oldest 41 minutes.' },  { value: 'draft', label: 'Draft', count: 4, tone: 'attention' as const, body: '4 reports started but not signed.' },  { value: 'signed', label: 'Signed today', count: 17, tone: 'neutral' as const, body: '17 reports signed since 08:00.' },]export default function TabsCounts() {  return (    <Tabs defaultValue="unread" className="w-full max-w-lg">      <TabsList>        {worklists.map(({ value, label, count, tone }) => (          <TabsTab key={value} value={value}>            {label}            {/* The count is decoration next to a name the tab already has, so                it needs no separate accessible label — but it must be part of                the tab's text, not a sibling, or the name reads as the number. */}            <Badge tone={tone} size="sm">{count}</Badge>          </TabsTab>        ))}      </TabsList>      {worklists.map(({ value, body }) => (        <TabsPanel key={value} value={value} className="text-muted-foreground text-sm">          {body}        </TabsPanel>      ))}    </Tabs>  )}

A worklist tab is more useful with its depth on it. Keep the count inside the TabsTab so it is part of the tab's accessible name — a badge rendered as a sibling reads as an unrelated "23", and one placed before the label makes the tab announce itself as a number.

Counts that change while the reader is looking at them are a live region problem, not a tabs problem. Do not add aria-live to a tab.

Controlled

8mm solid nodule, right upper lobe. No mediastinal lymphadenopathy.
Editing findings
'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import { Tabs, TabsList, TabsPanel, TabsTab } from '@/registry/qure/ui/tabs'export default function TabsControlled() {  const [tab, setTab] = React.useState<string>('findings')  return (    <div className="flex w-full max-w-md flex-col gap-4">      <Tabs value={tab} onValueChange={value => setTab(String(value))}>        <TabsList>          <TabsTab value="findings">Findings</TabsTab>          <TabsTab value="impression">Impression</TabsTab>          <TabsTab value="recommendation">Recommendation</TabsTab>        </TabsList>        <TabsPanel value="findings" className="text-sm">          8mm solid nodule, right upper lobe. No mediastinal lymphadenopathy.        </TabsPanel>        <TabsPanel value="impression" className="text-sm">          Indeterminate pulmonary nodule, unchanged from the March prior.        </TabsPanel>        <TabsPanel value="recommendation" className="text-sm">          Interval CT at 12 months per Fleischner criteria.        </TabsPanel>      </Tabs>      {/* Owning the value lets something outside the tab list move it — here,          a "next section" button in the report editor's footer. */}      <div className="flex items-center gap-2">        <Button          size="sm"          variant="secondary"          disabled={tab === 'recommendation'}          onClick={() => setTab(tab === 'findings' ? 'impression' : 'recommendation')}        >          Next section        </Button>        <span className="text-muted-foreground text-sm">Editing {tab}</span>      </div>    </div>  )}

Pass value and onValueChange when something outside the strip has to move the selection — a "next section" button, a deep link, or a validation failure that must reveal the tab holding the offending field. TabsTab.Value is any, so cast or narrow the value if your state is typed as a string.

Keyboard

Arrow keys move between tabs and select as they go, Home and End jump to the ends, and Tab from the strip lands on the panel rather than the next tab — one tab stop for the whole set. Under orientation="vertical" the arrows become Up and Down. All of it arrives with the primitive.

Base UI activates on focus, which is right for panels that are already rendered and wrong for panels that fetch. If a panel is expensive, render its shell immediately and load into it, rather than making arrow-key navigation fire four requests.

API Reference

Everything Base UI's Tabs accepts. What we add on TabsList:

Prop

Type

On Tabs:

Prop

Type

TabsTab takes value and disabled; TabsPanel takes value and keepMounted.

PartSelected state in the DOM
TabsTabdata-active and aria-selected="true" — there is no data-selected
TabsPanelmounted, and hidden when keepMounted is set

The stylesheet targets aria-selected. Reaching for data-selected by analogy with Select fails silently here: the attribute does not exist on a tab, the rule matches nothing, and the strip renders looking as though no tab is chosen.

On this page