FairGarden Design

Tabs

Base UI Tabs for 2–6 peer panels on one page: a caps tab list with a current bar, and one panel per tab.

import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from '@fairgarden-private/design/components/Tabs'

Tab labels are caps wayfinding: author them in sentence case and type-label sets the caps. The current tab takes a --ds-stroke-3 bar and weight 700; hover adds an accent underline under the label, told from the bar by its color and position, and a filled current cell lightens to --role-select-hover. Tabs never navigate between pages. In print the list is hidden and every panel prints in order under its label, so panels stay mounted. The root is an inline-size container, so give it a width in shrink-to-fit layouts.

Underline and segmented

Underline and segmented

underline is the default: tabs on a full-width baseline. Up to three tabs share the row as equal cells with wrapping labels; four to six hug their labels and scroll sideways in a Scroll Area, with a visible scrollbar and a › button; from 768 px of container width every row hugs its labels. segmented is a full-bleed bar of equal cells, and filled gives its current cell the selection fill. A disabled tab draws a dotted underline, but prefer removing it.

312 acres of wet meadow and oak savanna, protected since 1998.

Four to six tabs hug their labels; the row scrolls when it runs out of room.

Filled segmented cells belong on paper and white only.

Unfilled, the bar and weight alone mark the current cell.

TabsVariants.tsx
import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from '@fairgarden-private/design/components/Tabs'
import styles from './variants.module.css'

/**
 * Underline tabs (default), a longer underline row that scrolls with a ›
 * button, and the segmented bar, filled and bare.
 */
export function TabsVariants() {
  return (
    <div className={styles.stack}>
      <Tabs defaultValue="overview">
        <TabsList>
          <TabsTab value="overview">Overview</TabsTab>
          <TabsTab value="access">Trails and access</TabsTab>
          <TabsTab value="stewardship">Stewardship</TabsTab>
        </TabsList>
        <TabsPanel value="overview">
          <p className={styles.copy}>312 acres of wet meadow and oak savanna, protected since 1998.</p>
        </TabsPanel>
        <TabsPanel value="access">
          <p className={styles.copy}>Four marked trails, open dawn to dusk. Dogs on leash.</p>
        </TabsPanel>
        <TabsPanel value="stewardship">
          <p className={styles.copy}>Volunteers pull invasive buckthorn on the first Saturday of each month.</p>
        </TabsPanel>
      </Tabs>

      <Tabs defaultValue="easements">
        <TabsList>
          <TabsTab value="easements">Easements</TabsTab>
          <TabsTab value="acquisitions">Acquisitions</TabsTab>
          <TabsTab value="restoration">Restoration</TabsTab>
          <TabsTab value="monitoring">Monitoring</TabsTab>
          <TabsTab value="archive" disabled>
            Archive
          </TabsTab>
        </TabsList>
        <TabsPanel value="easements">
          <p className={styles.copy}>Four to six tabs hug their labels; the row scrolls when it runs out of room.</p>
        </TabsPanel>
        <TabsPanel value="acquisitions">
          <p className={styles.copy}>Land bought outright and held in trust.</p>
        </TabsPanel>
        <TabsPanel value="restoration">
          <p className={styles.copy}>Prairie seeding, wetland berms and prescribed burns.</p>
        </TabsPanel>
        <TabsPanel value="monitoring">
          <p className={styles.copy}>Annual visits to every protected parcel.</p>
        </TabsPanel>
        <TabsPanel value="archive">
          <p className={styles.copy}>Unavailable.</p>
        </TabsPanel>
      </Tabs>

      <Tabs variant="segmented" filled defaultValue="land">
        <TabsList>
          <TabsTab value="land">Protect land</TabsTab>
          <TabsTab value="water">Protect water and wetland habitat</TabsTab>
          <TabsTab value="people">Connect people</TabsTab>
        </TabsList>
        <TabsPanel value="land">
          <p className={styles.copy}>Filled segmented cells belong on paper and white only.</p>
        </TabsPanel>
        <TabsPanel value="water">
          <p className={styles.copy}>Long labels wrap to two lines instead of truncating.</p>
        </TabsPanel>
        <TabsPanel value="people">
          <p className={styles.copy}>Guided walks every weekend from April to October.</p>
        </TabsPanel>
      </Tabs>

      <Tabs variant="segmented" defaultValue="day">
        <TabsList>
          <TabsTab value="day">Day</TabsTab>
          <TabsTab value="week">Week</TabsTab>
          <TabsTab value="month">Month</TabsTab>
        </TabsList>
        <TabsPanel value="day">
          <p className={styles.copy}>Unfilled, the bar and weight alone mark the current cell.</p>
        </TabsPanel>
        <TabsPanel value="week">
          <p className={styles.copy}>Seven days of events.</p>
        </TabsPanel>
        <TabsPanel value="month">
          <p className={styles.copy}>The whole month at a glance.</p>
        </TabsPanel>
      </Tabs>
    </div>
  )
}

Primary and secondary

Primary and secondary

primary recolors labels, bar and baseline. secondary drives only the filled segmented cell; keep it equal to the brand strip's scale so strip and cell join.

Primary plum: labels, bar and baseline.

Secondary indigo: the filled cell and its edge.

TabsColor.tsx
import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from '@fairgarden-private/design/components/Tabs'
import styles from './color.module.css'

/** `primary` drives labels, bar and baseline; `secondary` only the filled segmented cell. */
export function TabsColor() {
  return (
    <div className={styles.stack}>
      <Tabs primary="plum" defaultValue="birds">
        <TabsList>
          <TabsTab value="birds">Birds</TabsTab>
          <TabsTab value="plants">Plants</TabsTab>
          <TabsTab value="insects">Insects</TabsTab>
        </TabsList>
        <TabsPanel value="birds">
          <p className={styles.copy}>Primary plum: labels, bar and baseline.</p>
        </TabsPanel>
        <TabsPanel value="plants">
          <p className={styles.copy}>Sedges, milkweed and bur oak.</p>
        </TabsPanel>
        <TabsPanel value="insects">
          <p className={styles.copy}>Monarchs and native bees.</p>
        </TabsPanel>
      </Tabs>

      <Tabs variant="segmented" filled secondary="indigo" defaultValue="visit">
        <TabsList>
          <TabsTab value="visit">Visit</TabsTab>
          <TabsTab value="volunteer">Volunteer</TabsTab>
          <TabsTab value="give">Give</TabsTab>
        </TabsList>
        <TabsPanel value="visit">
          <p className={styles.copy}>Secondary indigo: the filled cell and its edge.</p>
        </TabsPanel>
        <TabsPanel value="volunteer">
          <p className={styles.copy}>Workdays run year round.</p>
        </TabsPanel>
        <TabsPanel value="give">
          <p className={styles.copy}>Every gift stays local.</p>
        </TabsPanel>
      </Tabs>
    </div>
  )
}

On grounds

On paper and forest

Pass filled on paper and white only: deep and saturated grounds take no cell fill, and the bar and weight carry the current tab.

paper

Trailheads and parking.

Filled on a light ground.

forest

Trailheads and parking.

No cell fill on a deep ground.

TabsGrounds.tsx
import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from '@fairgarden-private/design/components/Tabs'
import { PresetGround } from '@/components/PresetGround'
import styles from './grounds.module.css'

const presets = ['paper', 'forest'] as const

/** The segmented cell fills on paper only; on forest the bar and weight carry "current". */
export function TabsGrounds() {
  return (
    <div className={styles.row}>
      {presets.map((preset) => (
        <PresetGround key={preset} preset={preset} className={styles.face}>
          <p className={styles.name}>{preset}</p>
          <Tabs defaultValue="map">
            <TabsList>
              <TabsTab value="map">Map</TabsTab>
              <TabsTab value="list">List</TabsTab>
            </TabsList>
            <TabsPanel value="map">
              <p className={styles.copy}>Trailheads and parking.</p>
            </TabsPanel>
            <TabsPanel value="list">
              <p className={styles.copy}>Every preserve, A to Z.</p>
            </TabsPanel>
          </Tabs>
          {preset === 'paper' ? (
            <Tabs variant="segmented" filled defaultValue="now">
              <TabsList>
                <TabsTab value="now">Now</TabsTab>
                <TabsTab value="later">Later</TabsTab>
              </TabsList>
              <TabsPanel value="now">
                <p className={styles.copy}>Filled on a light ground.</p>
              </TabsPanel>
              <TabsPanel value="later">
                <p className={styles.copy}>Upcoming.</p>
              </TabsPanel>
            </Tabs>
          ) : (
            <Tabs variant="segmented" defaultValue="now">
              <TabsList>
                <TabsTab value="now">Now</TabsTab>
                <TabsTab value="later">Later</TabsTab>
              </TabsList>
              <TabsPanel value="now">
                <p className={styles.copy}>No cell fill on a deep ground.</p>
              </TabsPanel>
              <TabsPanel value="later">
                <p className={styles.copy}>Upcoming.</p>
              </TabsPanel>
            </Tabs>
          )}
        </PresetGround>
      ))}
    </div>
  )
}

API Reference

Tabs

Tabs for 2–6 peer panels on one page (§9.5): TabsList holding TabsTabs, then one TabsPanel per tab. Labels are caps wayfinding (type-label), authored in sentence case [D165]. Never use tabs for page-to-page navigation or sequential steps. In print the list hides and every panel prints in order under its label. The root is an inline-size container, so give it a width in shrink-to-fit contexts.

PropTypeDescription
filled
boolean | undefined

Only with segmented.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: labels, bar, baseline and focus ring. Never defaulted; omitted, it inherits the scope [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: only the segmented current cell under filled. Keep it equal to the brand strip’s scale so strip and cell join.

variant
'underline' | 'segmented' | undefined

underline (default): tabs on a full-width --role-rule baseline; the current tab’s bar is as wide as its label.

TabsList and TabsTab

The tab list and its current-tab indicator. When 4–6 tabs overflow their row, the row scrolls sideways in a wide Scroll Area (the last tab clipped, a visible scrollbar, hard overflow edges) and a --size-px-7 › button at its end scrolls it on. More than 6 tabs belong in navigation or a Select.

PropTypeDescription
overflowLabel
string | undefined

Accessible name of the overflow › button that scrolls the row. Default “More tabs”.

One tab: an optional icon and a caps type-label label, at least --ds-size-hit tall. Inactive labels are --role-muted at weight 600, the current label --primary12 at 700 with the --ds-stroke-3 bar. Prefer removing a tab to disabling it.

PropTypeDescription
icon
| 'menu'
| 'search'
| 'circle'
| 'arrow_forward'
| 'arrow_upward'
| 'expand_more'
| 'close'
| 'remove'
| 'add'
| 'check'
| 'chevron_right'
| 'chevron_left'
| 'more_horiz'
| 'play_arrow'
| 'pause'
| 'download'
| 'zoom_in'
| 'zoom_out'
| 'recenter'
| 'help'
| 'mail'
| undefined

An optional leading icon, Material Symbols Rounded at the inline tier, FILL 0. Never icon-only.

TabsPanel

One panel, --size-px-5 below the list, with no border. Panels stay in the DOM while hidden (keepMounted, default true) so print can show every panel in order, each under its label in type-subhead.

PropTypeDescription
label
React.ReactNode | undefined

The heading this panel prints under. Omitted, it takes the matching tab’s label once the page has hydrated.

Additional types

tabs
type tabs = tabs
TabsListProps

Props for TabsList: Base UI Tabs List props plus the overflow button’s label.

type TabsListProps = TabsListProps & { overflowLabel?: string }
TabsPanelProps

Props for TabsPanel: Base UI Tab Panel props plus the printed label.

type TabsPanelProps = TabsPanelProps & { label?: React.ReactNode }
TabsProps

Props for Tabs: Base UI Tabs Root props (without orientation) plus the variant and color axes.

type TabsProps = (
  { variant?: 'underline'; filled?: undefined } | { variant: 'segmented'; filled?: boolean }
) & {
  /**
   * Primary Radix scale: labels, bar, baseline and focus ring. Never
   * defaulted; omitted, it inherits the scope [D133].
   */
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  /**
   * Secondary Radix scale: only the segmented current cell under `filled`.
   * Keep it equal to the brand strip's scale so strip and cell join.
   */
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
}
TabsTabProps

Props for TabsTab: Base UI Tab props plus an optional leading icon.

type TabsTabProps = TabsTabProps & {
  icon?:
    | 'menu'
    | 'search'
    | 'circle'
    | 'arrow_forward'
    | 'arrow_upward'
    | 'expand_more'
    | 'close'
    | 'remove'
    | 'add'
    | 'check'
    | 'chevron_right'
    | 'chevron_left'
    | 'more_horiz'
    | 'play_arrow'
    | 'pause'
    | 'download'
    | 'zoom_in'
    | 'zoom_out'
    | 'recenter'
    | 'help'
    | 'mail';
}

Specification: DESIGN-SYSTEM.md §9.5 (tabs) and §5.10.2 (reflow).