The tab strip: pipeline stages, settings sections, record detail views. Triggers carry the product vocabulary (a leading status dot, a trailing count badge, a second detail line, a disabled state with its own reason); panels are plain children keyed by the active value, or Tabs can be used purely as a switcher with the panel rendered by the caller below it (tabs.tsx:28-32).
The canon behind it. docs/design-language/07-toolbars-and-filters.md §A9 (07-toolbars-and-filters.md:39, a chip row switches categories, a facet narrows within one) and docs/design-language/03-status-language.md §A2 (03-status-language.md:16, StatusDot is named for tight spaces like tab triggers).
When to reach for it#
Tabs switches between different content panels for one record or one page section: which section of Settings, which per-module view of a part's preview, which raw record when a part has more than one. It is not a filter. Canon doc 7 §A9 draws a chip-row-versus-facet line for the toolbar's own filter chips: "A chip row switches categories. A facet narrows within one." Tabs is neither of those; it switches the rendered content itself, not what a list shows.
The interaction to know: Tabs's own variant="chip" is a visual style, chip-shaped triggers, not the doc 7 §A9 filter mechanism. walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:859-866 renders <Tabs variant="chip" color="success" .../> for its record-detail tab bar (overview / attributes / content / images / source), matching canon doc 10 §C's "chip tab bar" description. It still switches panels of one part; it does not filter a list. The real chip-row filter pattern from doc 7 §A9, an exhaustive category partition with live counts above a toolbar, ships as FacetChips/FilterBar in walmart-mvp/frontend/src/components/FacetToolbar.tsx, a different component entirely. Do not reach for Tabs variant="chip" to build that filter row; the two only look alike.
Contract#
items: TabItem<V>[](required): each item is{ value, label, icon?, disabled?, disabledReason?, badge?, status?, detail? }.label/detail:labelis the trigger's main line;detailis a second, smaller, monospaced line under it (e.g. "2,884/3,288 mapped"). No shipped usage ofdetailfound in either app.badge: a trailing slot on the trigger, for a count. Real usage:speedway/app/routes/account/accounts.tsx:369-374passesmembers.lengthon the Team tab;speedway/app/components/ProductItem.tsx:494-513computes a filtered attribute count for the Attributes tab badge, with an inline comment warning the count must match the section's own filter logic or "the badge overstates by one."status: a leadingStatusDot, for pipeline-stage tabs. No shipped usage found in either app, despite being named in the type's own doc comment as the pipeline-stage case.disabled/disabledReason: a disabled trigger, withdisabledReasonshown as a tooltip explaining why. No shipped usage of either found.
value/onChange(value, item)(required): value-first, the full item rides second.variant("underline" | "pill" | "enclosed" | "chip", default"underline") /size("sm" | "md" | "lg", default"md") /color(default"primary", resolved through the shared surface table):enclosedis used for a raw-record switcher (ProductItem.tsx:360-369);chipis used, withcolor="success", for the one record-detail tab bar cited above. Every other shipped usage leavesvariantandcolorat their defaults.children: panel content for the active tab, rendered byTabsitself. Omit it to useTabspurely as a switcher strip and render the panel separately below the call (see Sanctioned combinations).grow: stretches triggers to share the strip's full width equally. No shipped usage found.skeleton: a row of skeleton bars, one per item (minimum three), instead of real triggers.- Slot classnames per
TabsClassNames:className,listClassName,triggerClassName,activeTriggerClassName,panelClassName.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
items with a badge count, size="md", default variant | A settings-style tab strip where one tab's population is visible up front | speedway/app/routes/account/accounts.tsx:363-380 | The plainest real shape: three tabs, one badged, driven by a URL search param |
Conditional render, Tabs only mounted when there is more than one item, otherwise the bare single content renders directly | A switcher that disappears entirely rather than showing one inert tab | speedway/app/components/part-detail-tabs.tsx (guarded by panels[tab] fallback) and ProductItem.tsx:359, 494 (records.length > 1, docs.length > 1) | Recurring pattern across every raw-record and multi-file tab strip in speedway; a one-item Tabs is never shipped |
Tabs given children, rendering its own panel inline | A self-contained switcher, tab strip and content in one component | speedway/app/components/ProductItem.tsx:298-312 (Tabs wrapping a CodeBlock as children) | The panel is small and local enough that owning it inside Tabs is simpler than a separate active === value branch |
Tabs given no children, with the caller branching on value below it | A switcher strip whose panel is a larger, separately-composed block | ProductItem.tsx:494-513 + :402 (branches on tab), part-detail-tabs.tsx:396-402, PartPreviewModal.tsx:859-874 | Used whenever the panel itself needs its own layout/state beyond what fits as a single children node |
variant="chip" color="success" size="sm" | A pill-styled record-detail tab bar | PartPreviewModal.tsx:859-866 | Matches canon doc 10 §C's named "chip tab bar" for the walmart part-preview record modal |
Banned combinations#
Do not build canon doc 7 §A9's filter chip row (an exhaustive category partition with live counts, sitting above a list's toolbar) out of Tabs variant="chip". They render similarly but do different jobs: Tabs is role="tablist"/aria-selected panel-switching for one record, doc 7 §A9's chip row is a single-select filter over a list, implemented separately as FacetChips in FacetToolbar.tsx.
Do not confuse the kit's Tabs with walmart-mvp/frontend/src/components/ui/tabs.tsx, a wholly separate local wrapper over Radix TabsPrimitive, exporting its own Tabs/TabsList/TabsTrigger/TabsContent. It is imported and used in walmart-mvp/frontend/src/pages/FileDetail.tsx:12 and walmart-mvp/frontend/src/features/catalog/CatalogDetail.tsx:12, both from '@/components/ui/tabs', never from @versable-git/ui. The two share a name and a general shape but not a prop contract; a TabItem-shaped prop set does not apply to the local component and vice versa.
Before you adopt this#
Five questions to answer before reaching for Tabs.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Tabs is page content; no shell already renders it.
- Does this app already ship a local implementation of the same thing?
walmart-mvp/.../ui/tabs.tsxis a separate Radix wrapper sharing the name; check the import. - Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed Tabs prop is noted in this doc.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them? A
disabledReasontab wraps itself inTooltip; other triggers need no extra wiring. - Which canon §A rules bind this surface, and which does the composition break? §A9 keeps a chip-shaped filter row a facet, never a
Tabs variant="chip".
Travels with#
StatusDot, rendered internally when an item's status is set (no shipped call site yet, but the wiring exists at tabs.tsx:145).
Tooltip, wrapping any trigger that carries a disabledReason (tabs.tsx:160-166, likewise unused in shipped code so far).
CodeBlock and other panel content, either as Tabs's own children or as a caller-owned block switched by the same value, per the two sanctioned shapes above.
Snippet#
// speedway/app/routes/account/accounts.tsx:363-380<Tabs size="md" className="mt-2" items={[ { value: "profile", label: "Profile", icon: "User" }, { value: "team", label: "Team", badge: members.length, icon: "Team" }, { value: "usage", label: "Usage", icon: "Star" }, ]} value={tab} onChange={(v) => setParams(v === "profile" ? {} : { tab: v }, { replace: true })}/>