Agent docs

Tabs

The tab strip: pipeline stages, settings sections, record detail views.

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: label is the trigger's main line; detail is a second, smaller, monospaced line under it (e.g. "2,884/3,288 mapped"). No shipped usage of detail found in either app.
    • badge: a trailing slot on the trigger, for a count. Real usage: speedway/app/routes/account/accounts.tsx:369-374 passes members.length on the Team tab; speedway/app/components/ProductItem.tsx:494-513 computes 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 leading StatusDot, 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, with disabledReason shown 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): enclosed is used for a raw-record switcher (ProductItem.tsx:360-369); chip is used, with color="success", for the one record-detail tab bar cited above. Every other shipped usage leaves variant and color at their defaults.
  • children: panel content for the active tab, rendered by Tabs itself. Omit it to use Tabs purely 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#

CombinationProducesWhere usedWhy
items with a badge count, size="md", default variantA settings-style tab strip where one tab's population is visible up frontspeedway/app/routes/account/accounts.tsx:363-380The 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 directlyA switcher that disappears entirely rather than showing one inert tabspeedway/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 inlineA self-contained switcher, tab strip and content in one componentspeedway/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 itA switcher strip whose panel is a larger, separately-composed blockProductItem.tsx:494-513 + :402 (branches on tab), part-detail-tabs.tsx:396-402, PartPreviewModal.tsx:859-874Used 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 barPartPreviewModal.tsx:859-866Matches 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.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, Tabs is page content; no shell already renders it.
  2. Does this app already ship a local implementation of the same thing? walmart-mvp/.../ui/tabs.tsx is a separate Radix wrapper sharing the name; check the import.
  3. 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.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? A disabledReason tab wraps itself in Tooltip; other triggers need no extra wiring.
  5. 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 })}
/>
@versable-git/ui · reference, canon, and method, read in place