Skip to content

Side tabs

A vertical tab list for master and detail pages that wraps to a strip on small screens.

Status
Beta
Category
Navigation
Adoption
Not used yet
import { SideTabs } from "@oration/canon/components/side-tabs";
packages/canon/src/components/side-tabs.tsx

AP clerkSystem

Enters invoices, matches remittances and requests W-9s from suppliers.

9 members have this role in Cedarline.

import { Button } from "@oration/canon/components/button";import { SideTabs, sideTabId } from "@oration/canon/components/side-tabs";import { Tag } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const roles = [        {            id: "role-admin",            name: "Admin",            members: 2,            system: true,            description:                "Full access, including billing, members and workspace settings.",        },        {            id: "role-approver",            name: "Approver",            members: 4,            system: true,            description:                "Approves payment runs and invoices over the workspace limit.",        },        {            id: "role-clerk",            name: "AP clerk",            members: 9,            system: true,            description:                "Enters invoices, matches remittances and requests W-9s from suppliers.",        },        {            id: "role-auditor",            name: "Auditor",            members: 1,            system: false,            description:                "Reads every record and exports reports. Can't change anything.",        },    ];    const [value, setValue] = React.useState("role-clerk");    const panelId = React.useId();    const selected = roles.find((role) => role.id === value) ?? roles[0];    return (        <div className="grid w-full max-w-3xl gap-6 text-left xl:grid-cols-[12rem_minmax(0,1fr)]">            <SideTabs                label="Roles"                panelId={panelId}                value={value}                onValueChange={setValue}                items={roles.map((role) => ({                    id: role.id,                    label: role.name,                    meta: `${role.members} ${role.members === 1 ? "member" : "members"}`,                    badge: role.system ? null : (                        <Tag color="indigo">Custom</Tag>                    ),                }))}            />            {selected ? (                <div                    role="tabpanel"                    id={panelId}                    aria-labelledby={sideTabId(selected.id)}                    className="flex min-w-0 flex-col gap-4 self-start rounded-xl bg-card p-4 shadow-border"                >                    <div className="flex flex-wrap items-start justify-between gap-3">                        <div className="min-w-0">                            <h3 className="flex items-center gap-2 text-sm font-semibold">                                {selected.name}                                {selected.system ? <Tag>System</Tag> : null}                            </h3>                            <p className="mt-1 text-13 text-muted-foreground">                                {selected.description}                            </p>                        </div>                        <Button                            type="button"                            variant="outline"                            size="sm"                            onClick={() =>                                toast.add({                                    title: `${selected.name} duplicated`,                                    description:                                        "Rename it and adjust permissions, then save.",                                })                            }                        >                            Duplicate                        </Button>                    </div>                    <p className="rounded-[10px] bg-muted/70 px-3 py-2 text-13">                        {selected.members}{" "}                        {selected.members === 1 ? "member has" : "members have"}{" "}                        this role in Cedarline.                    </p>                </div>            ) : null}        </div>    );}

Usage#

Side tabs are the vertical list on master and detail settings pages: roles, objects, queues, dispositions and ticket types. Each item names one sibling, with an optional icon, a line of meta such as 9 members and a trailing tag, and the panel beside it shows the selected one. Below 1280px the list wraps into a horizontal strip above the panel. The part people get wrong is the panel: Side tabs render only the tab list, so you render one role="tabpanel" yourself and label it with sideTabId.

When to use

  • For a settings page that edits one of several siblings of the same kind: Roles, Queues, Disposition sets, Ticket types.
  • When each item benefits from a second line of meta, such as a member or record count.
  • For three to about fifteen items that fit without scrolling. Longer lists need search, which is a combobox or a table.
  • Beside a panel that is long enough to justify keeping the list in view while you edit.

When not to use

  • For two to six views of one record, such as Overview and Activity. Use Tabs
  • For the app's own navigation between pages and sections. Use Sidebar
  • For choosing one option in a form that is saved with the rest of it. Use Radio group
  • For a long, searchable list of siblings, such as every supplier. Use Data grid

The Thirteen-Fourteen Rule

Labels are Body Dense at 13px and meta is 12px in tabular figures. The selected label turns medium weight without shifting its neighbours, because an invisible bold copy reserves its width.

The Option Hue Rule

A trailing tag carries a real option value such as Custom or System. Don't use tag hues to color-code the items themselves.

Anatomy#

  1. Tab list. A tablist named by label. Vertical with 1px gaps from 1280px; below that it wraps into a strip with 4px gaps.
  2. Icon. Optional, 16px, Slate Meta at rest and ink when selected. Always decorative.
  3. Label. 13px, one line, truncated. Medium weight when selected, with the width reserved so nothing jumps.
  4. Meta. Optional 12px Slate Meta line under the label in tabular figures, such as 9 members.
  5. Badge. Optional trailing node, usually a Tag such as Custom.
  6. Selected fill. Menu Hover (--accent) on a 10px-corner item at least 32px tall.

Examples#

Orientation

The default "responsive" layout wraps into a strip below 1280px and stacks from 1280px, following the viewport. Pin "vertical" or "horizontal" when the container decides, as in a sheet or a narrow card.

Routing and overflow for Priority suppliers.

Codes agents pick after inbound calls.

import { SideTabs, sideTabId } from "@oration/canon/components/side-tabs";import * as React from "react";export function Orientation() {    const queues = [        { id: "queue-priority", label: "Priority suppliers", meta: "3 agents" },        { id: "queue-billing", label: "Billing questions", meta: "6 agents" },        { id: "queue-w9", label: "W-9 follow-up", meta: "2 agents" },    ];    const sets = [        { id: "set-inbound", label: "Inbound calls" },        { id: "set-email", label: "Email" },        { id: "set-callbacks", label: "Callbacks" },    ];    const [queue, setQueue] = React.useState("queue-priority");    const [set, setSet] = React.useState("set-inbound");    const queuePanel = React.useId();    const setPanel = React.useId();    return (        <div className="grid w-full max-w-2xl gap-8 md:grid-cols-2">            <div className="flex gap-4">                <SideTabs                    label="Queues"                    panelId={queuePanel}                    value={queue}                    onValueChange={setQueue}                    orientation="vertical"                    items={queues}                    className="w-44 shrink-0"                />                <p                    role="tabpanel"                    id={queuePanel}                    aria-labelledby={sideTabId(queue)}                    className="text-13 text-muted-foreground"                >                    Routing and overflow for{" "}                    {queues.find((item) => item.id === queue)?.label}.                </p>            </div>            <div className="flex flex-col gap-3">                <SideTabs                    label="Disposition sets"                    panelId={setPanel}                    value={set}                    onValueChange={setSet}                    orientation="horizontal"                    items={sets}                />                <p                    role="tabpanel"                    id={setPanel}                    aria-labelledby={sideTabId(set)}                    className="text-13 text-muted-foreground"                >                    Codes agents pick after{" "}                    {sets.find((item) => item.id === set)?.label.toLowerCase()}.                </p>            </div>        </div>    );}

Icons, meta and badges

An icon helps tell kinds apart, the meta line gives one fact that helps choose, and a trailing tag carries a real option value such as Custom.

Short pay dispute

14 attributes

import { SideTabs, sideTabId } from "@oration/canon/components/side-tabs";import { Tag } from "@oration/canon/components/tag";import { BanknoteIcon, FileBadgeIcon, ReceiptIcon, ScaleIcon } from "lucide-react";import * as React from "react";export function WithIconsAndBadges() {    const types = [        {            id: "type-billing",            label: "Billing question",            icon: <ReceiptIcon />,            meta: "8 attributes",            badge: <Tag>Default</Tag>,        },        {            id: "type-short-pay",            label: "Short pay dispute",            icon: <ScaleIcon />,            meta: "14 attributes",            badge: <Tag color="indigo">Custom</Tag>,        },        {            id: "type-w9",            label: "W-9 request",            icon: <FileBadgeIcon />,            meta: "5 attributes",        },        {            id: "type-remittance",            label: "Remittance resend",            icon: <BanknoteIcon />,            meta: "6 attributes",        },    ];    const [value, setValue] = React.useState("type-short-pay");    const panelId = React.useId();    const selected = types.find((type) => type.id === value);    return (        <div className="flex w-full max-w-xl gap-6">            <SideTabs                label="Ticket types"                panelId={panelId}                value={value}                onValueChange={setValue}                orientation="vertical"                items={types}                className="w-56 shrink-0"            />            <div                role="tabpanel"                id={panelId}                aria-labelledby={sideTabId(value)}                className="min-w-0 text-13"            >                <p className="font-medium">{selected?.label}</p>                <p className="text-muted-foreground">{selected?.meta}</p>            </div>        </div>    );}

Disabled item

A disabled item dims to 50% and arrow keys skip it. Use its meta line to say why, here the plan that unlocks it.

Last change: Wen Zhou invited Aisha Bello on Monday, Sep 28.

import { SideTabs, sideTabId } from "@oration/canon/components/side-tabs";import * as React from "react";export function Disabled() {    const [value, setValue] = React.useState("log-members");    const panelId = React.useId();    return (        <div className="flex w-full max-w-lg gap-6">            <SideTabs                label="Audit logs"                panelId={panelId}                value={value}                onValueChange={setValue}                orientation="vertical"                className="w-48 shrink-0"                items={[                    { id: "log-members", label: "Members", meta: "212 events" },                    {                        id: "log-payments",                        label: "Payment runs",                        meta: "48 events",                    },                    {                        id: "log-api",                        label: "API keys",                        meta: "Enterprise plan",                        disabled: true,                    },                ]}            />            <p                role="tabpanel"                id={panelId}                aria-labelledby={sideTabId(value)}                className="text-13 text-muted-foreground"            >                {value === "log-members"                    ? "Last change: Wen Zhou invited Aisha Bello on Monday, Sep 28."                    : "Last change: Maya Okafor approved the Friday run."}            </p>        </div>    );}

States#

States
StateTreatment
RestNo fill. Label in ink, icon and meta in Slate Meta.
HoverOne highlight at 60% Menu Hover glides to the item under the pointer on the fast spring. It fades in at the first item you enter rather than sliding from where it last was.
Focus visibleA 3px Focus Indigo ring at 40% around the item.
SelectedFull Menu Hover fill, medium-weight label and an ink icon. aria-selected is true and the item is the one tab stop.
Disabled50% opacity, not clickable, and skipped by arrow keys. Use the meta line to say why.

Behavior#

  • Controlled only: pass value and update it in onValueChange. There is no internal state.
  • Selection follows focus. Arrow keys, Home and End select the next item immediately and move focus to it, wrapping at the ends and skipping disabled items.
  • Down and Right both move forward and Up and Left both move back, so the same keys work in the vertical list and the wrapped strip.
  • orientation="responsive" (the default) is a wrapping strip below 1280px and a vertical list from 1280px. Pin it with "vertical" or "horizontal" when the container, not the viewport, decides.
  • Render one panel beside the list with role="tabpanel", id={panelId} and aria-labelledby={sideTabId(value)}, and swap its content by the selected id.
  • Item ids become DOM ids through sideTabId(id), so they must be unique on the page. Prefix them when two lists could share an id.
  • Settings pages lay the list and panel out as grid gap-6 xl:grid-cols-[12rem_minmax(0,1fr)], so the strip sits above the panel until the list moves beside it.
  • Under reduced motion the hover highlight fades between items instead of sliding.

Do and don't#

Do. Put the count on the meta line in Slate Meta, and keep the label to the item's name.
Don't. Pack the count and the kind into the label. It truncates first, and the list stops scanning as names.
Do. Use side tabs when the page edits one of many siblings, such as roles or queues, and the panel is long.
Don't. Use side tabs for two or three views of one record. That is Tabs, above the content, where record pages keep them.
Do. Label the panel with aria-labelledby={sideTabId(value)} so it is announced with the selected item's name.
Don't. Render the panel as an unlabelled div. Screen reader users hear a change of content with no name for it.

Content#

  • Labels are the item's own name in sentence case: AP clerk, Short pay dispute, Priority suppliers.
  • Meta is one short fact that helps choose, with its unit: 9 members, 14 attributes, 3 queues.
  • Badges are tags with one word: Custom, System, Default. Never a sentence.
  • The list's label names the set in the plural: Roles, Ticket types.

Accessibility#

  • The list is a tablist named by label. Each item is a tab with aria-selected, aria-controls={panelId} and an id from sideTabId.
  • Roving tab stop: only the selected item has tabIndex 0. If value matches no enabled item, nothing is reachable with Tab, so always select one.
  • Icons are aria-hidden; the label, meta and badge are all read as the tab's name, so keep them short.
  • Items are at least 32px tall, above the 24px minimum target.
  • The hover highlight is decorative and aria-hidden.
Keyboard interactions
KeysAction
TabMoves focus to the selected item, then on into the panel.
↓→Selects and focuses the next enabled item, wrapping at the end.
↑←Selects and focuses the previous enabled item, wrapping at the start.
HomeSelects the first enabled item.
EndSelects the last enabled item.

Design tokens#

Design tokens
TokenUsed for
--accentSelected fill, and the hover glide at 60%
--foregroundLabels, and the selected icon
--muted-foregroundIcons at rest and the meta line
--ring3px focus ring at 40%
--radius-lg10px item corners
text-13Label size
spring.fastThe hover highlight's glide

API reference#

SideTabs

The vertical tab list. Also exported: sideTabId(id), which returns the DOM id of an item (side-tab- plus the id) for the panel's aria-labelledby, and the SideTabItem type.

Other props spread onto Nothing. Only the props below are accepted..

Props of SideTabs
PropTypeDefaultDescription
labelRequiredstringNo defaultThe accessible name of the tab list, such as Roles.
itemsRequired{ id: string; label: ReactNode; meta?: ReactNode; badge?: ReactNode; icon?: ReactNode; disabled?: boolean }[]No defaultThe items, in order. id must be unique on the page because it becomes a DOM id.
valueRequiredstringNo defaultThe id of the selected item.
onValueChangeRequired(id: string) => voidNo defaultCalled on click and on every arrow, Home and End move.
panelIdRequiredstringNo defaultThe id of the panel the tabs control. Every tab points at it with aria-controls.
orientation"responsive" | "vertical" | "horizontal""responsive"responsive wraps into a strip below 1280px and stacks from 1280px. The others pin one layout.
classNamestringNo defaultMerged onto the list.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

sideTabId returns side-tab- plus the item id, with no per-instance prefix, so two lists on one page that share an item id produce duplicate DOM ids and a panel labelled by the wrong tab.

When value matches no enabled item, every tab gets tabIndex -1 and the list can't be reached with Tab. The roles settings page passes selected?.id ?? "", which hits this when no role is selected.

In responsive mode the list reports aria-orientation="vertical" even while it is laid out as a horizontal strip below 1280px.

Items use 10px corners. DESIGN.md gives view tabs and nav items the 8px small corner.

Side tabs have no disabled reason, no count slot separate from meta, and no search. Long sibling lists fall back to hand-built tables.