Side tabs
A vertical tab list for master and detail pages that wraps to a strip on small screens.
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
The Option Hue Rule
Anatomy#
- Tab list. A
tablistnamed bylabel. Vertical with 1px gaps from 1280px; below that it wraps into a strip with 4px gaps. - Icon. Optional, 16px, Slate Meta at rest and ink when selected. Always decorative.
- Label. 13px, one line, truncated. Medium weight when selected, with the width reserved so nothing jumps.
- Meta. Optional 12px Slate Meta line under the label in tabular figures, such as 9 members.
- Badge. Optional trailing node, usually a Tag such as Custom.
- 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#
| State | Treatment |
|---|---|
| Rest | No fill. Label in ink, icon and meta in Slate Meta. |
| Hover | One 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 visible | A 3px Focus Indigo ring at 40% around the item. |
| Selected | Full Menu Hover fill, medium-weight label and an ink icon. aria-selected is true and the item is the one tab stop. |
| Disabled | 50% opacity, not clickable, and skipped by arrow keys. Use the meta line to say why. |
Behavior#
- Controlled only: pass
valueand update it inonValueChange. 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}andaria-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#
aria-labelledby={sideTabId(value)} so it is announced with the selected item's name.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
labelnames the set in the plural: Roles, Ticket types.
Accessibility#
- The list is a
tablistnamed bylabel. Each item is atabwitharia-selected,aria-controls={panelId}and an id fromsideTabId. - Roving tab stop: only the selected item has
tabIndex0. Ifvaluematches 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.
| Keys | Action |
|---|---|
| Tab | Moves 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. |
| Home | Selects the first enabled item. |
| End | Selects the last enabled item. |
Design tokens#
| Token | Used for |
|---|---|
--accent | Selected fill, and the hover glide at 60% |
--foreground | Labels, and the selected icon |
--muted-foreground | Icons at rest and the meta line |
--ring | 3px focus ring at 40% |
--radius-lg | 10px item corners |
text-13 | Label size |
spring.fast | The 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..
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | string | No default | The accessible name of the tab list, such as Roles. |
itemsRequired | { id: string; label: ReactNode; meta?: ReactNode; badge?: ReactNode; icon?: ReactNode; disabled?: boolean }[] | No default | The items, in order. id must be unique on the page because it becomes a DOM id. |
valueRequired | string | No default | The id of the selected item. |
onValueChangeRequired | (id: string) => void | No default | Called on click and on every arrow, Home and End move. |
panelIdRequired | string | No default | The 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. |
className | string | No default | Merged 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.