Skip to content

Tabs ​

Many views of one thing in a narrow pane, each tab with how much its view holds: "Links 12", "Attachments 2". A compound component on Base UI's tabs. To pick one of a few modes, such as list or grid, use Segmented instead.

tsx
import { Tabs } from '@adecore/ui';
tsx
import { useState } from 'react';
import { Tabs } from '@adecore/ui';

const VIEWS = [
    { id: 'preview', label: 'Preview', count: 0 },
    { id: 'text', label: 'Plain text', count: 0 },
    { id: 'source', label: 'HTML source', count: 0 },
    { id: 'headers', label: 'Headers', count: 24 },
    { id: 'links', label: 'Links', count: 12 },
    { id: 'attachments', label: 'Attachments', count: 2 },
    { id: 'checks', label: 'Checks', count: 3 },
    { id: 'raw', label: 'Raw', count: 0 }
];

export default function TabsDemo() {
    const [view, setView] = useState('links');

    return (
        <Tabs.Root value={view} onValueChange={setView} className="w-full max-w-3xl rounded-lg border border-border bg-surface">
            <Tabs.List aria-label="Message views" className="px-4">
                {VIEWS.map((entry) => (
                    <Tabs.Tab key={entry.id} value={entry.id}>
                        {entry.label}
                        <Tabs.Count value={entry.count} />
                    </Tabs.Tab>
                ))}
            </Tabs.List>
            {VIEWS.map((entry) => (
                <Tabs.Panel key={entry.id} value={entry.id} className="p-4 text-xs text-text-muted">
                    {entry.count === 0 ? `${entry.label} of the message.` : `${entry.count} ${entry.label.toLowerCase()} in the message.`}
                </Tabs.Panel>
            ))}
        </Tabs.Root>
    );
}
tsx
<Tabs.Root value={view} onValueChange={setView}>
    <Tabs.List aria-label="Message views" className="px-4">
        <Tabs.Tab value="links">
            Links
            <Tabs.Count value={links.length} />
        </Tabs.Tab>
    </Tabs.List>
    <Tabs.Panel value="links">…</Tabs.Panel>
</Tabs.Root>

Parts ​

PartWhat it is
Tabs.RootHolds the picked tab: value and onValueChange(value), or defaultValue.
Tabs.ListThe strip, with a border along its bottom and a menu for the tabs that do not fit. Name it with aria-label.
Tabs.TabOne tab, with its value. disabled keeps it out of reach.
Tabs.CountThe number after a label, written the way the region writes numbers. Nothing at zero.
Tabs.PanelThe view of one tab, shown while that tab is picked.

The strip ​

The picked tab is underlined over the strip's border, the others are muted. The strip has no padding of its own along the sides, so its border can run the width of the pane; className on Tabs.List gives the tabs their inset, such as px-4.

Tabs that do not fit ​

The strip never scrolls or wraps. It shows the tabs that fit from the start, and "More" at its end opens a menu with the rest, each with its icon and its count. Picking one there makes it the picked tab. The picked tab always stays in the strip: when it is one that would not fit, it takes the place of the last tab before "More". The demo below puts eight views in a pane too narrow for them; drag its corner to make it wider.

tsx
import { useState } from 'react';
import { Tabs } from '@adecore/ui';

const VIEWS = [
    { id: 'preview', label: 'Preview', count: 0 },
    { id: 'text', label: 'Plain text', count: 0 },
    { id: 'source', label: 'HTML source', count: 0 },
    { id: 'headers', label: 'Headers', count: 24 },
    { id: 'links', label: 'Links', count: 12 },
    { id: 'attachments', label: 'Attachments', count: 2 },
    { id: 'checks', label: 'Checks', count: 3 },
    { id: 'raw', label: 'Raw', count: 0 }
];

/* A pane a person can drag narrower and wider by its corner, to watch tabs move into the menu and back. */
export default function TabsOverflowDemo() {
    const [view, setView] = useState('preview');

    return (
        <div className="w-80 max-w-full min-w-40 resize-x overflow-hidden rounded-lg border border-border bg-surface">
            <Tabs.Root value={view} onValueChange={setView}>
                <Tabs.List aria-label="Message views" className="px-3">
                    {VIEWS.map((entry) => (
                        <Tabs.Tab key={entry.id} value={entry.id}>
                            {entry.label}
                            <Tabs.Count value={entry.count} />
                        </Tabs.Tab>
                    ))}
                </Tabs.List>
                <p className="p-3 text-xs text-text-muted">Drag the corner of this pane to make it wider or narrower.</p>
            </Tabs.Root>
        </div>
    );
}

The strip measures its tabs before it draws, so nothing jumps on the first paint. It reads what a tab holds from its props, so the tabs are the direct children of Tabs.List, or an array of them.

Panels ​

Tabs.Panel holds the view of one tab and links it to its tab for a screen reader. Only the panel of the picked tab is mounted, unless it has keepMounted. A screen that draws its view itself, below the strip, can leave the panels out.

Icons ​

A tab takes an Icon before its label, at 14 pixels.

tsx
import { useState } from 'react';
import { Eye, Link, Paperclip, ShieldCheck } from 'lucide-react';
import { Icon, Tabs } from '@adecore/ui';

export default function TabsIconsDemo() {
    const [view, setView] = useState('preview');

    return (
        <Tabs.Root value={view} onValueChange={setView} className="w-full max-w-md">
            <Tabs.List aria-label="Message views">
                <Tabs.Tab value="preview">
                    <Icon icon={Eye} size={14} /> Preview
                </Tabs.Tab>
                <Tabs.Tab value="links">
                    <Icon icon={Link} size={14} /> Links
                    <Tabs.Count value={12} />
                </Tabs.Tab>
                <Tabs.Tab value="attachments">
                    <Icon icon={Paperclip} size={14} /> Attachments
                    <Tabs.Count value={2} />
                </Tabs.Tab>
                <Tabs.Tab value="checks" disabled>
                    <Icon icon={ShieldCheck} size={14} /> Checks
                </Tabs.Tab>
            </Tabs.List>
        </Tabs.Root>
    );
}

Keyboard ​

The arrow keys move between tabs and wrap around at the ends, Home and End go to the first and the last, and Enter or Space picks the focused one. With activateOnFocus on Tabs.List, landing on a tab picks it. Tab moves on to the panel. A disabled tab is skipped.

TabsRootProps, TabsListProps, TabsCountProps and TabsValue are exported types.