Gesso
Navigation

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

PropTypeDefaultWhat it does
tabs{ id: string; label: string; icon?: ComponentType; isActive?: boolean }[]noneThe options, in order. label names the item, icon is drawn by variant="icon", and the tab with isActive is on.
onTabChange(tabId: string) => voidnoneCalled with the id of the tab that was picked.
variant"icon" | "label""icon"icon shows dots that open into icons. label shows text pills.
classNamestringnoneAdded 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 a radiogroup and each item is a radio with aria-checked. There are no tab or tabpanel roles.
  • Every item gets an aria-label from its label, 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 tabs array with an isActive.
  • 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-switcher

That pulls Toggle group with it. For new work you can install shadcn add @gesso/toggle-group alone and skip the tabs array.

On this page