Tabs

A tabbed interface for switching between related content panels.

import { ... } from '@corndev/ui'

Overview panel — the default active tab.

All items.

HTML

<div class="corn-tabs" data-variant="line">
  <div class="corn-tabs-tablist" role="tablist">
    <button class="corn-tabs-tab" data-active role="tab" data-tab="one">One <span class="corn-tabs-tab-section" data-position="right">3</span></button>
    <button class="corn-tabs-tab" role="tab" data-tab="two">Two</button>
  </div>
  <div class="corn-tabs-panel" role="tabpanel" data-tab="one">First panel</div>
  <div class="corn-tabs-panel" role="tabpanel" data-tab="two" hidden>Second panel</div>
</div>

Classes

Class
.corn-tabs
.corn-tabs-tablist
.corn-tabs-tab
.corn-tabs-panel
.corn-tabs-tab-section

Attributes

OnAttributeValues
.corn-tabsdata-variantline|pills
.corn-tabs-tabdata-activepresent or absent

Vanilla JS

Required for behaviour — adds switches panels on click and arrow keys, and manages aria-selected/hidden.

import { createTabs } from '@corndev/ui';
import '@corndev/ui/theme';

const containerEl = document.querySelector('#target');
const instance = createTabs(containerEl, { /* options */ });

Options

OptionTypeDefaultDescription
defaultTabstringundefineddata-tab of the initially active tab. Defaults to the first tab in the markup.
variant'line'|'pills''line'Underlined tabs, or filled pills.
onChange(value: string) => voidundefinedCalled with the newly active tab’s data-tab value.

React

Tabs, Tab — from @corndev/ui-react.

Only the active panel is mounted, so switching destroys the others’ state. When that state cannot be lifted above Tabs — an editor’s undo history and cursor, a panel that queries on mount — mark that one Tab keepMounted: it is mounted the first time it is shown and kept, hidden, afterwards, so it is still not built until someone opens it. Tabs may be written out, mapped from a list, or both, including inside fragments. leftSection and rightSection put an icon or a count inside the tab button, beside the label; the vanilla equivalent is a .corn-tabs-tab-section span in your own markup. A child that is not a Tab is dropped, with a development warning.

Props

PropTypeDefaultDescription
valuestringundefinedActive tab (controlled). Pair with onChange. Use it when the tab list itself changes — if the active tab stops existing, only the caller knows what to fall back to.
defaultTabstringundefinedvalue of the initially active tab (uncontrolled). Defaults to the first Tab.
variant'line'|'pills''line'
onChange(value: string) => voidundefinedCalled with the newly active tab’s value.
classNamestringundefinedMerged with the Corn UI classes.
childrenReact.ReactNoderequiredTab elements.

<Tab> props

A single tab. Never rendered directly — Tabs reads its props.

PropTypeDefaultDescription
valuestringrequiredIdentifier, matched against defaultTab.
labelReact.ReactNoderequiredTab button content.
disabledbooleanfalse
keepMountedbooleanfalseKeep this panel mounted, hidden, once it has been shown, so its state survives a switch — a code editor’s text, undo history and cursor, or a panel that queries on mount. Mounted on first activation rather than up front, so an expensive panel is still not built until someone opens it.
leftSectionReact.ReactNodeundefinedBefore the label inside the tab button — an icon.
rightSectionReact.ReactNodeundefinedAfter the label — a count or a badge. Both sections sit inside the button, so they must not be interactive.
childrenReact.ReactNode|(() => React.ReactNode)undefinedPanel body. A function is called only when the panel is mounted.

Default

<Tabs>
  <Tab value="overview" label="Overview">
    Overview panel content.
  </Tab>
  <Tab value="details" label="Details">
    Details panel content.
  </Tab>
  <Tab value="settings" label="Settings">
    Settings panel content.
  </Tab>
</Tabs>

Pills variant

<Tabs variant="pills">
  <Tab value="all" label="All">All items.</Tab>
  <Tab value="active" label="Active">Active items only.</Tab>
  <Tab value="archived" label="Archived">Archived items.</Tab>
</Tabs>

Default active tab

<Tabs defaultTab="b">
  <Tab value="a" label="Tab A">Panel A</Tab>
  <Tab value="b" label="Tab B">Panel B — opened by default.</Tab>
  <Tab value="c" label="Tab C">Panel C</Tab>
</Tabs>

Keeping a panel mounted

Only the active panel is mounted — a tab is usually a whole view, and building five to show one is wasted work. The trade is that switching tabs destroys the other panels’ state.

When that state cannot live above Tabs — a code editor’s text, undo history and cursor, or a panel that queries a database when it mounts — mark that tab keepMounted:

<Tabs>
  <Tab value="query" label="Query" keepMounted>
    <SqlEditor />
  </Tab>
  <Tab value="results" label="Results">
    <ResultsGrid />
  </Tab>
</Tabs>

It is mounted the first time it is shown, then kept, hidden, from then on. So an expensive panel is still not built until someone opens it, and it survives every switch after that.

Tabs from a list

Tabs can be written out, mapped from data, or both — including inside a fragment:

<Tabs>
  <Tab value="overview" label="Overview">…</Tab>
  {environments.map(env => (
    <Tab key={env.id} value={env.id} label={env.name}>…</Tab>
  ))}
</Tabs>

Icons and counts

leftSection and rightSection sit inside the tab button, beside the label:

<Tab value="issues" label="Issues" leftSection={<IconBug />} rightSection={<Badge>3</Badge>}>
  …
</Tab>

They are inside a button, so they must not be interactive themselves. Without React, put a .corn-tabs-tab-section span in your own tab button — the class is the whole API.