Tabbed features
Capability areas behind manual-activation tabs.
Live preview
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.
Related
All sectionsTabs
ARIA tabs with manual activation and three appearances.
intermediate5 variantsAlternating feature rows
Copy and visual swapping sides down the page, with DOM order held constant.
intermediateFeatured3 variantsCompetitive comparison
A three-way comparison against approaches rather than named competitors.
intermediate3 variants