Tab switcher
A well of dots or text pills from a tabs array, built on Toggle group.
Tab switcher picks one view out of a short list, like Light and Dark or List and Grid, from
a tabs array. It is sugar over the well and the dots of
Toggle group, so it looks and behaves the same way,
and it owns no panels. Any number of tabs works. By default each tab is a dot that opens into
its icon when the pointer is over the switcher, and variant="label" shows text pills
instead.
Usage
import { Moon, Sun } from '@phosphor-icons/react';
import { TabSwitcher } from '@/components/ui/tab-switcher';
<TabSwitcher
tabs={[
{ id: 'light', label: 'Light mode', icon: Sun, isActive: mode === 'light' },
{ id: 'dark', label: 'Dark mode', icon: Moon, isActive: mode === 'dark' },
]}
onTabChange={setMode}
/>You own the state. The switcher marks the tab whose isActive is true, and onTabChange
receives the id of the tab that was picked, so set isActive from that id and the
selection follows. With no tab isActive, every tab is off until you set one, rather than
the switcher tracking a pick of its own. Pressing the tab that is already on does nothing, so
onTabChange never fires with an empty value. An empty tabs array renders nothing.
Dots for icons
The default variant="icon" is Toggle group's density="dots". Each tab is an 8px dot in
the sunken well, and the one that is on is bg-primary. While the pointer is over the
switcher or focus is inside it, the dots open into icons, and the selected icon turns
text-primary. A touch screen has no hover, so there it stays open with 44px items. Each tab
needs an icon, because a tab without one renders no dot content, and its label is the
name of the item and the tooltip that shows below it.
Text pills
variant="label" is Toggle group's default well, with the label of each tab as a pill. The
well is 40px tall, the tab that is on rises onto bg-surface-raised and turns
text-primary, and the others stay text-fg-muted until hovered. icon is not used.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
tabs | { id: string; label: string; icon?: ComponentType; isActive?: boolean }[] | none | The options, in order. label names the item, icon is drawn by variant="icon", and the tab with isActive is on. |
onTabChange | (tabId: string) => void | none | Called with the id of the tab that was picked. |
variant | "icon" | "label" | "icon" | icon shows dots that open into icons. label shows text pills. |
className | string | none | Added to the well. |
There is no size, spacing, or multiple selection. For those, use Toggle group directly.
Same thing in Toggle group
<ToggleGroup type="single" density="dots" value={mode} onValueChange={setMode}>
<ToggleGroupItem value="light" aria-label="Light mode">
<Sun />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark mode">
<Moon />
</ToggleGroupItem>
</ToggleGroup>Accessibility
- Despite the name, it is not a tablist. It is a Toggle group with
type="single", so the group is aradiogroupand each item is aradiowitharia-checked. There are no tab or tabpanel roles. - Every item gets an
aria-labelfrom itslabel, in both variants, so an icon-only dot has a name. - The group is one tab stop, and Tab lands on the item that is on. The arrow keys move between items and wrap at the ends, and Home and End jump to the first and last. Arrows only move focus, so Space or Enter picks an item.
- Focus shows a 2px ring on
:focus-visible, and the focused item rises above its neighbors so the ring is not covered. - On a touch screen each text pill gets a 44px hit area around it without changing its size, and the dots open to 44px items.
- Under reduced motion the dots, the icons, and the raised pill change without animating.
- In forced colors the tab that is on has no visible mark, because the system replaces its
raised fill, and the collapsed dots are fills too. Assistive tech still reports the state
from
aria-checked.
When to use something else
- Toggle group for new work that holds the value
directly, needs several items on at once, or wants the joined and outline looks. Tab
switcher wins only when the options already arrive as a
tabsarray with anisActive. - Tabs when each option owns a panel of content, with the tab roles and an edge mark.
- Sidebar when the options are the places of the app.
- Stepper when the options are ordered steps.
Install
shadcn add @gesso/tab-switcherThat pulls Toggle group with it. For new work you can install shadcn add @gesso/toggle-group
alone and skip the tabs array.