Skip to content

Tabbed features

Capability areas behind manual-activation tabs.

Marketingintermediatefeaturestabscapabilitieskeyboard

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { Container } from '@/components/ui/layout'
import { Tabs } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Metric } from '@/components/ui/metric'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'

/**
 * Tabbed features
 *
 * For products with distinct capability areas that deserve real estate but not
 * a whole page each. Uses manual tab activation, so arrowing across four tabs
 * does not mount four panels.
 */
const rows = [
  { id: 'r1', name: 'Editorial', accent: 'Indigo', radius: 'Tight', display: 'Serif' },
  { id: 'r2', name: 'Corporate', accent: 'Navy', radius: 'Tight', display: 'Grotesque' },
  { id: 'r3', name: 'Minimal', accent: 'Monochrome', radius: 'Square', display: 'Grotesque' },
  { id: 'r4', name: 'Modern', accent: 'Teal', radius: 'Generous', display: 'Grotesque' },
  { id: 'r5', name: 'Warm', accent: 'Terracotta', radius: 'Soft', display: 'Serif' },
]

export default function TabbedFeatures() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Three capability areas, one component set.
          </h2>
        </div>

        <div className="mt-10">
          <Tabs
            label="Capability areas"
            appearance="enclosed"
            items={[
              {
                id: 'theming',
                label: 'Theming',
                content: (
                  <Table
                    caption="Palette characteristics"
                    rows={rows}
                    rowKey={(row) => row.id}
                    responsive="stack"
                    columns={[
                      {
                        key: 'name',
                        header: 'Palette',
                        cell: (row) => (
                          <span className="font-medium text-ink-strong">{row.name}</span>
                        ),
                      },
                      { key: 'accent', header: 'Accent', cell: (row) => row.accent },
                      { key: 'radius', header: 'Radius', cell: (row) => row.radius },
                      { key: 'display', header: 'Display type', cell: (row) => row.display },
                    ]}
                  />
                ),
              },
              {
                id: 'performance',
                label: 'Performance',
                content: (
                  <div className="grid gap-3 sm:grid-cols-3">
                    <Metric label="JS on a landing page" value="14 kB" delta={-78} invertTrend />
                    <Metric label="Routes prerendered" value="100%" />
                    <Metric label="Runtime dependencies" value="0" />
                  </div>
                ),
              },
              {
                id: 'accessibility',
                label: 'Accessibility',
                badge: (
                  <Badge size="sm" tone="success">
                    Audited
                  </Badge>
                ),
                content: (
                  <ul className="flex flex-col gap-3">
                    <li>
                      <Status
                        kind="operational"
                        label="Focus is trapped and returned in every overlay"
                      />
                    </li>
                    <li>
                      <Status
                        kind="operational"
                        label="Tabs, menus and the palette follow ARIA patterns"
                      />
                    </li>
                    <li>
                      <Status kind="operational" label="Status never relies on colour alone" />
                    </li>
                    <li>
                      <Status
                        kind="degraded"
                        label="Automated checks cannot replace a manual audit"
                      />
                    </li>
                  </ul>
                ),
              },
            ]}
          />
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/features/tabbed.tsx

'use client'

import { Container } from '@/components/ui/layout'
import { Tabs } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Metric } from '@/components/ui/metric'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'

/**
 * Tabbed features
 *
 * For products with distinct capability areas that deserve real estate but not
 * a whole page each. Uses manual tab activation, so arrowing across four tabs
 * does not mount four panels.
 */
const rows = [
  { id: 'r1', name: 'Editorial', accent: 'Indigo', radius: 'Tight', display: 'Serif' },
  { id: 'r2', name: 'Corporate', accent: 'Navy', radius: 'Tight', display: 'Grotesque' },
  { id: 'r3', name: 'Minimal', accent: 'Monochrome', radius: 'Square', display: 'Grotesque' },
  { id: 'r4', name: 'Modern', accent: 'Teal', radius: 'Generous', display: 'Grotesque' },
  { id: 'r5', name: 'Warm', accent: 'Terracotta', radius: 'Soft', display: 'Serif' },
]

export default function TabbedFeatures() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Three capability areas, one component set.
          </h2>
        </div>

        <div className="mt-10">
          <Tabs
            label="Capability areas"
            appearance="enclosed"
            items={[
              {
                id: 'theming',
                label: 'Theming',
                content: (
                  <Table
                    caption="Palette characteristics"
                    rows={rows}
                    rowKey={(row) => row.id}
                    responsive="stack"
                    columns={[
                      {
                        key: 'name',
                        header: 'Palette',
                        cell: (row) => (
                          <span className="font-medium text-ink-strong">{row.name}</span>
                        ),
                      },
                      { key: 'accent', header: 'Accent', cell: (row) => row.accent },
                      { key: 'radius', header: 'Radius', cell: (row) => row.radius },
                      { key: 'display', header: 'Display type', cell: (row) => row.display },
                    ]}
                  />
                ),
              },
              {
                id: 'performance',
                label: 'Performance',
                content: (
                  <div className="grid gap-3 sm:grid-cols-3">
                    <Metric label="JS on a landing page" value="14 kB" delta={-78} invertTrend />
                    <Metric label="Routes prerendered" value="100%" />
                    <Metric label="Runtime dependencies" value="0" />
                  </div>
                ),
              },
              {
                id: 'accessibility',
                label: 'Accessibility',
                badge: (
                  <Badge size="sm" tone="success">
                    Audited
                  </Badge>
                ),
                content: (
                  <ul className="flex flex-col gap-3">
                    <li>
                      <Status
                        kind="operational"
                        label="Focus is trapped and returned in every overlay"
                      />
                    </li>
                    <li>
                      <Status
                        kind="operational"
                        label="Tabs, menus and the palette follow ARIA patterns"
                      />
                    </li>
                    <li>
                      <Status kind="operational" label="Status never relies on colour alone" />
                    </li>
                    <li>
                      <Status
                        kind="degraded"
                        label="Automated checks cannot replace a manual audit"
                      />
                    </li>
                  </ul>
                ),
              },
            ]}
          />
        </div>
      </Container>
    </section>
  )
}

components/ui/tabs.tsx

'use client'

import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Tabs
 *
 * Implements the ARIA tabs pattern with *manual* activation: arrow keys move
 * focus, Enter or Space activates. Automatic activation is the wrong default
 * for a documentation site, where a panel may be expensive and a keyboard user
 * arrowing past three tabs should not mount all three.
 *
 * Home/End jump to the ends, and the tab list scrolls rather than wrapping on
 * narrow screens so the active tab is always reachable.
 */
export interface TabItem {
  id: string
  label: ReactNode
  content: ReactNode
  disabled?: boolean
  /** Optional count or status rendered after the label. */
  badge?: ReactNode
}

export interface TabsProps {
  items: TabItem[]
  defaultTab?: string
  /** Controlled selection. */
  value?: string
  onValueChange?: (id: string) => void
  appearance?: 'underline' | 'enclosed' | 'pill'
  className?: string
  label?: string
}

export function Tabs({
  items,
  defaultTab,
  value: controlledValue,
  onValueChange,
  appearance = 'underline',
  className,
  label = 'Tabs',
}: TabsProps) {
  const uid = useId()
  const [uncontrolled, setUncontrolled] = useState(defaultTab ?? items[0]?.id ?? '')
  const active = controlledValue ?? uncontrolled
  const refs = useRef<Record<string, HTMLButtonElement | null>>({})

  const select = (id: string) => {
    if (controlledValue === undefined) setUncontrolled(id)
    onValueChange?.(id)
  }

  const enabled = items.filter((item) => !item.disabled)

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
    const index = enabled.findIndex((item) => item.id === id)
    if (index === -1) return
    let nextIndex: number | null = null

    if (event.key === 'ArrowRight') nextIndex = (index + 1) % enabled.length
    else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + enabled.length) % enabled.length
    else if (event.key === 'Home') nextIndex = 0
    else if (event.key === 'End') nextIndex = enabled.length - 1
    else if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault()
      select(id)
      return
    }

    if (nextIndex === null) return
    event.preventDefault()
    const nextId = enabled[nextIndex]?.id
    if (nextId) refs.current[nextId]?.focus()
  }

  const activeItem = items.find((item) => item.id === active) ?? items[0]

  const listClasses = {
    underline: 'flex gap-1 border-b border-line',
    enclosed: 'flex gap-1 rounded-md border border-line bg-surface-sunken p-1',
    pill: 'flex flex-wrap gap-1.5',
  }[appearance]

  return (
    <div className={cn('flex flex-col', className)}>
      <div
        className={cn(
          'hide-scrollbar overflow-x-auto',
          appearance === 'underline' && 'border-b border-line',
        )}
      >
        <div
          role="tablist"
          aria-label={label}
          className={cn(listClasses, appearance === 'underline' && 'border-b-0')}
        >
          {items.map((item) => {
            const selected = item.id === active
            return (
              <button
                key={item.id}
                ref={(el) => {
                  refs.current[item.id] = el
                }}
                type="button"
                role="tab"
                id={`${uid}-tab-${item.id}`}
                aria-selected={selected}
                aria-controls={`${uid}-panel-${item.id}`}
                tabIndex={selected ? 0 : -1}
                disabled={item.disabled}
                onClick={() => select(item.id)}
                onKeyDown={(event) => onKeyDown(event, item.id)}
                className={cn(
                  'inline-flex shrink-0 items-center gap-2 text-sm font-medium whitespace-nowrap transition-colors duration-150',
                  'disabled:cursor-not-allowed disabled:opacity-50',
                  appearance === 'underline' &&
                    cn(
                      '-mb-px border-b-2 px-3 py-2',
                      selected
                        ? 'border-accent text-ink-strong'
                        : 'border-transparent text-ink-muted hover:text-ink',
                    ),
                  appearance === 'enclosed' &&
                    cn(
                      'rounded-sm px-3 py-1.5',
                      selected
                        ? 'bg-surface text-ink-strong shadow-xs'
                        : 'text-ink-muted hover:text-ink',
                    ),
                  appearance === 'pill' &&
                    cn(
                      'rounded-full border px-3 py-1',
                      selected
                        ? 'border-accent bg-accent-soft text-accent-soft-ink'
                        : 'border-line text-ink-muted hover:border-line-strong hover:text-ink',
                    ),
                )}
              >
                {item.label}
                {item.badge}
              </button>
            )
          })}
        </div>
      </div>

      {activeItem ? (
        <div
          role="tabpanel"
          id={`${uid}-panel-${activeItem.id}`}
          aria-labelledby={`${uid}-tab-${activeItem.id}`}
          tabIndex={0}
          className="pt-4 focus-visible:outline-2 focus-visible:outline-offset-2"
        >
          {activeItem.content}
        </div>
      ) : null}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For products with distinct capability areas that deserve real estate but not a page each. Manual activation means arrowing across four tabs does not mount four panels.

  • Keep panel heights similar, or switching tabs jumps the page.
  • Tab labels should be nouns, not verbs.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Three panels
  • Manual activation
  • Table, metrics and status content

Accessibility

ARIA tabs
tablist, tab and tabpanel with both-direction wiring.
Manual activation
Arrow keys move focus; Enter or Space activates.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.