[Feature] Tabs: match shadcn (Base UI + vega)

Merged
CI
2d ago
+601-64

Brings Tabs in line with shadcn's Base UI build and the vega style, following the shadcn audit run on 2026-11-04.

Before / after

RubyUI on main, RubyUI with this PR, and shadcn (Base UI + vega), same fixture.

Closed (light):
Closed light

After clicking Password (light):
Open light

Keyboard focus (light):
Focus light

Closed (dark):
Closed dark

After clicking Password (dark):
Open dark

Keyboard focus (dark):
Focus dark

New in the docs: the line variant and a vertical list.

Line variant Vertical

What changed

  • Vega classes on every part. The root is a flex column with gap-2, the list uses p-[3px], and the trigger has a transparent border, px-2, the ring-[3px] focus ring, a hover color and bg-input/30 with a border when active in dark mode. Panels are text-sm.
  • role tablist/tab/tabpanel, aria-selected, aria-controls on every tab and aria-labelledby on every panel. Before this, screen readers saw three loose buttons.
  • Keyboard handling follows Base UI: a roving tabindex, arrow keys that wrap, Home/End, activation on Enter/Space, and a focusable panel. Leaving the list puts the tab stop back on the selected tab.
  • Disabled tabs use aria-disabled + data-disabled and stay reachable with the arrow keys, as in Base UI.
  • Parts carry data-slot, data-orientation, data-active and data-variant, as shadcn renders them.
  • New options: Tabs(orientation: :vertical), TabsList(variant: :line, activate_on_focus:, loop_focus:), and a ruby-ui--tabs:change event with {value, previousValue}.
  • Tabs now takes default_value:, shadcn's defaultValue. The docs and the home page already passed it, but the component ignored it and rendered a stray default-value attribute. default: still works as an alias.
  • Audit map, fixtures, divergences and baseline for tabs, plus the rebuilt MCP registry.

Two deliberate differences are registered in audit/divergences/tabs.yml:

  • Every panel stays in the page, hidden with the hidden attribute. Base UI unmounts inactive panels, which leaves inactive tabs without aria-controls. Keeping them follows the APG.
  • There's no value/onValueChange/keepMounted. Setting data-ruby-ui--tabs-active-value switches tabs, and the change event reports the switch.

Breaking changes

  • The controller no longer sets data-state="active|inactive" on triggers. Custom CSS that uses data-[state=active]: needs data-active: (or aria-selected:) instead.
  • TabsTrigger(disabled: true) now renders aria-disabled instead of the native disabled attribute.
  • Inactive panels are hidden with the hidden attribute instead of the hidden class.
  • Panel text is text-sm and the panel's mt-2 is gone. The spacing now comes from the root's gap-2.

Follow-ups

  • The panel is focusable (tabindex=0), but it has outline-none and no ring, as in shadcn. WCAG 2.4.7 asks for a visible focus indicator. This could be a registered divergence with a focus-visible ring. (Codex review)
  • With nested tabs, group-data-[orientation=…]/tabs: matches any Tabs ancestor. A horizontal list inside vertical tabs would pick up the vertical styles. shadcn has the same issue. Keying the styles on each element's own data-orientation would fix it, but that attribute only exists after the controller runs. (Codex review)

Audit checklists

main (before)
shadcn audit  ruby-ui/ruby_ui
  base 1998c5e8  head 1998c5e8  shadcn 8a353667
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme  neutral tokens match shadcn
Tabs  (ref: bases/base/ui/tabs.tsx + style-vega.css @ 8a353667)
  ⏸ Look       focus/light, focus/dark not reached on shadcn: trigger never received keyboard focus. Expected: Base UI uses a roving tabindex, so Tab lands on the active tab (Account), never on the inactive data-audit-trigger. Focus was driven by hand instead (Behavior and focus-ring Look lines; screens tabs-focus-*). Kept as is: settled by hand (roving tabindex is expected). Requested: run `baseline tabs 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR, and commit audit/component_map/tabs.yml + audit/fixtures/tabs/ (created locally for this audit).
  ❌ Look       column-gap: normal; display: block; gap: normal; row-gap: normal
               vega: column-gap: 8px; display: flex; gap: 8px; row-gap: 8px
               Tabs — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. RubyUI Tabs has no classes (block); shadcn `flex data-horizontal:flex-col` + vega `.cn-tabs { gap-2 }`. Requested: Tabs root `flex flex-col gap-2` (classes picked in Ruby, no data-horizontal: variant), drop TabsContent mt-2, TabsList p-[3px].
               ↳ bases/base/ui/tabs.tsx:17 + style-vega.css:1317
  ❌ Look       display: inline-flex; padding-bottom: 4px; padding-left: 4px; padding-right: 4px; padding-top: 4px
               vega: display: flex; padding-bottom: 3px; padding-left: 3px; padding-right: 3px; padding-top: 3px
               TabsList — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. RubyUI `p-1` vs vega `p-[3px]` (style-vega.css:1321). display differs only because the parent Tabs is block in RubyUI (inline-flex is blockified inside shadcn's flex column); heights match (h-9 both). Requested: Tabs root `flex flex-col gap-2` (classes picked in Ruby, no data-horizontal: variant), drop TabsContent mt-2, TabsList p-[3px].
               ↳ style-vega.css:1321
  ❌ Look       background-color: oklch(0.145 0 0); border-bottom-color: oklch(0.922 0 0); border-bottom-color: oklch(1 0 0 / 0.1); border-bottom-width: 0px; border-left-color: oklch(0.922 0 0); border-left-color: oklch(1 0 0 / 0.1); border-left-width: 0px; border-right-color: oklch(0.922 0 0); border-right-color: oklch(1 0 0 / 0.1); border-right-width: 0px; border-top-color: oklch(0.922 0 0); border-top-color: oklch(1 0 0 / 0.1); border-top-width: 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(0.145 0 0) 0px 0px 0px 2px, oklch(0.556 0 0) 0px 0px 0px 4px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(1 0 0) 0px 0px 0px 2px, oklch(0.708 0 0) 0px 0px 0px 4px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2px -…; color: oklch(0.556 0 0); column-gap: normal; gap: normal; outline-color: oklab(0.556 0 0 / 0.5); outline-color: oklab(0.708 0 0 / 0.5); outline-style: none; padding-left: 12px; padding-right: 12px; row-gap: normal
               vega: background-color: oklab(1 0 0 / 0.045); border-bottom-color: oklch(0.708 0 0); border-bottom-color: oklch(1 0 0 / 0.15); border-bottom-color: rgba(0, 0, 0, 0); border-bottom-width: 1px; border-left-color: oklch(0.708 0 0); border-left-color: oklch(1 0 0 / 0.15); border-left-color: rgba(0, 0, 0, 0); border-left-width: 1px; border-right-color: oklch(0.708 0 0); border-right-color: oklch(1 0 0 / 0.15); border-right-color: rgba(0, 0, 0, 0); border-right-width: 1px; border-top-color: oklch(0.708 0 0); border-top-color: oklch(1 0 0 / 0.15); border-top-color: rgba(0, 0, 0, 0); border-top-width: 1px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.556 0 0 / 0.5) 0px 0px 0px 3px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px…; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.708 0 0 / 0.5) 0px 0px 0px 3px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px…; color: oklab(0.145 0 0 / 0.6); column-gap: 6px; gap: 6px; outline-color: oklch(0.556 0 0); outline-color: oklch(0.708 0 0); outline-style: solid; padding-left: 8px; padding-right: 8px; row-gap: 6px
               TabsTrigger — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. Tokens: RubyUI `px-3`, no border, no gap vs vega `px-2 gap-1.5 border border-transparent` (style-vega.css:1325); inactive light text RubyUI `text-muted-foreground` (oklch 0.556) vs shadcn `text-foreground/60`; dark active RubyUI `bg-background` vs shadcn `dark:data-active:border-input dark:data-active:bg-input/30` (bases/base/ui/tabs.tsx:62). Width differences follow from padding. Requested: port tabs.tsx:60-62 + .cn-tabs-trigger to TabsTrigger, translating data-active:/group-data variants to plain Tailwind (data-[active]:).
               ↳ style-vega.css:1325 + bases/base/ui/tabs.tsx:60-62
  ⏸ Look       properties: font-family, font-size, height, line-height, width
               Tabs, TabsList, TabsTrigger, TabsContent — explained by environment diff: font. Checked per element: font-family differs only in the fallback list (both render Inter Variable). The height/font-size/line-height differences on TabsTrigger and TabsContent are NOT environmental: see the two Look ❌ lines below (h-[calc(100%-1px)] and text-sm). Kept as is: environment-only (fallback list); real diffs split into their own lines.
  ⏸ ARIA       focus/light, focus/dark not reached on shadcn: trigger never received keyboard focus. Expected: Base UI uses a roving tabindex, so Tab lands on the active tab (Account), never on the inactive data-audit-trigger. Focus was driven by hand instead (Behavior and focus-ring Look lines; screens tabs-focus-*). Kept as is: settled by hand (roving tabindex is expected).
  ⚠️ ARIA       snapshot: only here: - button "Account" | - button "Password" | - button "Billing" [disabled] | - text: Change your password here.; snapshot: only here: - button "Account" | - button "Password" | - button "Billing" [disabled] | - text: Make changes to your account here.
               shadcn: snapshot: only here: - tablist: |   - tab "Account" [selected] |   - tab "Password" |   - tab "Billing" [disabled] | - tabpanel "Account": Make changes to your account here.; snapshot: only here: - tablist: |   - tab "Account" |   - tab "Password" [selected] |   - tab "Billing" [disabled] | - tabpanel "Password": Change your password here.
               Tabs — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. RubyUI renders plain buttons/divs: no role=tablist/tab/tabpanel, no aria-selected, no aria-controls/aria-labelledby. Requested: Tabs root `flex flex-col gap-2` (classes picked in Ruby, no data-horizontal: variant), drop TabsContent mt-2, TabsList p-[3px].
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - button "Account" | - button "Password" | - button "Billing" [disabled]
               shadcn: snapshot: only here: - tablist: |   - tab "Account" [selected] |   - tab "Password" |   - tab "Billing" [disabled]; snapshot: only here: - tablist: |   - tab "Account" |   - tab "Password" [selected] |   - tab "Billing" [disabled]
               TabsList — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. RubyUI renders plain buttons/divs: no role=tablist/tab/tabpanel, no aria-selected, no aria-controls/aria-labelledby. Requested: Tabs root `flex flex-col gap-2` (classes picked in Ruby, no data-horizontal: variant), drop TabsContent mt-2, TabsList p-[3px].
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       aria-controls: (none); snapshot: only here: - button "Account"; snapshot: only here: - button "Billing" [disabled]; snapshot: only here: - button "Password"
               shadcn: aria-controls: resolves; snapshot: only here: - tab "Account"; snapshot: only here: - tab "Account" [selected]; snapshot: only here: - tab "Billing" [disabled]; snapshot: only here: - tab "Password"; snapshot: only here: - tab "Password" [selected]
               TabsTrigger — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. RubyUI renders plain buttons/divs: no role=tablist/tab/tabpanel, no aria-selected, no aria-controls/aria-labelledby. Requested: port tabs.tsx:60-62 + .cn-tabs-trigger to TabsTrigger, translating data-active:/group-data variants to plain Tailwind (data-[active]:).
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - text: Change your password here.; snapshot: only here: - text: Make changes to your account here.
               shadcn: snapshot: only here: - tabpanel "Account": Make changes to your account here.; snapshot: only here: - tabpanel "Password": Change your password here.
               TabsContent — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. RubyUI renders plain buttons/divs: no role=tablist/tab/tabpanel, no aria-selected, no aria-controls/aria-labelledby. Requested: role tablist/tab/tabpanel, aria-selected, aria-controls + aria-labelledby with generated ids; keep every panel mounted so all tabs get aria-controls.
               ↳ accessibility tree (shadcn-audit collect)
  ❌ Look       TabsTrigger py-1 → height 28px
               vega: h-[calc(100%-1px)] → height 29px (36px list − 2×3px padding − 1px)
               Real size difference hidden under the font env ⏸; same in every state and theme. Requested: port tabs.tsx:60-62 + .cn-tabs-trigger to TabsTrigger, translating data-active:/group-data variants to plain Tailwind (data-[active]:).
               ↳ bases/base/ui/tabs.tsx:60
  ❌ Look       TabsContent mt-2, no font size → 16px/24px
               vega: text-sm (vega .cn-tabs-content) → 14px/20px; spacing from Tabs gap-2 instead of mt-2
               font-size/line-height differ independently of the font fallback list. mt-2 vs gap-2 both give 8px. Requested: TabsContent `flex-1 text-sm outline-none` (drop ring-offset focus classes and mt-2).
               ↳ style-vega.css:1333
  ❌ Look       focus-visible: ring-2 ring-ring ring-offset-2 (2px background offset + solid 2px ring)
               vega: focus-visible: border-ring ring-[3px] ring-ring/50 outline-1 outline-ring (no offset)
               Computed on keyboard focus: RubyUI box-shadow `0 0 0 2px oklch(1 0 0), 0 0 0 4px oklch(0.708 0 0)`, outline none; shadcn `0 0 0 3px oklab(0.708 0 0 / 0.5)` + `outline: 1px solid oklch(0.708 0 0)` + border oklch(0.708 0 0). Screens tabs-focus-*. Requested: port tabs.tsx:60-62 + .cn-tabs-trigger to TabsTrigger, translating data-active:/group-data variants to plain Tailwind (data-[active]:).
               ↳ bases/base/ui/tabs.tsx:60
  ❌ Look       inactive trigger hover: color stays oklch(0.556 0 0)
               vega: hover:text-foreground → oklch(0.145 0 0)
               Hovered the inactive Password tab in light. Requested: port tabs.tsx:60-62 + .cn-tabs-trigger to TabsTrigger, translating data-active:/group-data variants to plain Tailwind (data-[active]:).
               ↳ bases/base/ui/tabs.tsx:60
  ✅ Look       active trigger shadow (0 1px 3px, 0 1px 2px -1px rgb(0 0 0/.1))
               RubyUI `shadow` and v4 `shadow-sm` compute to the same layers.
               ↳ style-vega.css:1325
  ❌ HTML       no data-slot/data-orientation on any part; active state as data-state=active|inactive
               shadcn: data-slot=tabs|tabs-list|tabs-trigger|tabs-content, data-orientation, data-active, data-disabled, data-variant on the list
               Read from the rendered DOM of both fixtures. Requested: emit data-slot per part, data-orientation, data-active/data-disabled on triggers, data-variant on the list; style with data-[active]:.
               ↳ bases/base/ui/tabs.tsx:14-15,58
  ❌ HTML       all three TabsContent rendered; inactive ones hidden with the `hidden` CSS class (display:none)
               shadcn: only the active panel is in the DOM (Base UI Tabs.Panel keepMounted=false)
               With keepMounted RubyUI would match by keeping panels mounted; the hiding mechanism (class vs hidden attribute) is the difference worth noting. Requested: keep panels mounted, toggle the `hidden` attribute instead of the class. Suggested divergence (audit/divergences/tabs.yml): HTML, panels stay mounted (server-rendered; lets every tab carry aria-controls).
               ↳ bases/base/ui/tabs.tsx:73
  ❌ HTML       disabled TabsTrigger: native `disabled` attribute
               shadcn: aria-disabled="true" + data-disabled, button stays focusable (tabindex -1, reachable by arrows)
               Base UI keeps disabled tabs focusable inside the tablist. Requested: disabled emits aria-disabled=true + data-disabled; controller refuses activation; arrows still reach it.
               ↳ bases/base/ui/tabs.tsx:57
  ✅ Props      Tabs(default:)
               Mapped under another name. Docs examples pass `default_value:`, which the component ignores: it renders as a `default-value="account"` HTML attribute (checked on /docs/tabs), so docs fall back to the first trigger by luck. `as` (TabsTrigger, renders an <a>) is RubyUI-only. Requested: accept default_value: (shadcn defaultValue), keep default: as a deprecated alias.
               ↳ bases/base/ui/tabs.tsx:11
  ❌ Props      none
               shadcn: Tabs: value + onValueChange (controlled), orientation (horizontal|vertical)
               No vertical layout; no controlled API (Stimulus value could serve). Requested: orientation: (:horizontal/:vertical, ↑/↓ keys). Suggested divergence (Props): value/onValueChange replaced by a ruby-ui--tabs:change Stimulus event.
               ↳ bases/base/ui/tabs.tsx:9
  ❌ Props      none
               shadcn: TabsList variant (default|line)
               The `line` variant (underline indicator via after:) is missing. Requested: TabsList(variant: :default/:line) with the after: underline indicator.
               ↳ bases/base/ui/tabs.tsx:30-31
  ❌ Props      none
               shadcn: TabsList activateOnFocus, loopFocus; TabsContent keepMounted (Base UI primitive)
               RubyUI always keeps panels mounted (≈ keepMounted=true) and has no keyboard focus model, so the list props have nothing to configure. Requested: TabsList activate_on_focus: (false) and loop_focus: (true); keepMounted covered by the suggested HTML divergence.
               ↳ .shadcn-audit/sandbox/node_modules/@base-ui/react/tabs (1.9.0)
  ✅ Behavior   click on an inactive tab activates it and shows its panel; Enter/Space on a focused tab activate it (native button)
               Driven in Chromium on both fixtures (Password tab).
               ↳ bases/base/ui/tabs.tsx:57
  ⚠️ Behavior   no arrow keys, Home/End do nothing; every enabled tab is in the Tab order (no tabindex management)
               shadcn: roving tabindex (active tab 0, others -1); ←/→ move focus with wrap, Home/End jump, manual activation (Enter/Space)
               Sequence Tab, →×3, Enter, Home, End, ←, Space, Tab, Tab on both sides. RubyUI focus stayed on Account for every arrow/Home/End press. Requested: APG keyboard in the controller: roving tabindex, ←/→ with wrap, Home/End, manual activation; TabsContent tabindex=0.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#keyboard_interaction
  ⚠️ Behavior   Tab from the tab list goes to the next tab; panel not focusable
               shadcn: Tab from the tablist moves to the tabpanel (tabindex=0), then out
               Observed with keyboard on both sides. Requested: APG keyboard in the controller: roving tabindex, ←/→ with wrap, Home/End, manual activation; TabsContent tabindex=0.
               ↳ bases/base/ui/tabs.tsx:73
  ⚠️ Behavior   disabled tab skipped by Tab (native disabled)
               shadcn: disabled tab focusable via arrows, not activatable
               Clicking the disabled tab changes nothing on either side. Requested: disabled emits aria-disabled=true + data-disabled; controller refuses activation; arrows still reach it.
               ↳ bases/base/ui/tabs.tsx:57
  ✅ Animation  transition-all 150ms on trigger; panel swaps instantly
               Computed transition on both; no animation on panel change in either.
               ↳ bases/base/ui/tabs.tsx:60
  ✅ Native     JS toggles a `hidden` class and data-state
               No row of native-html.md applies: HTML has no tabs control, so the Stimulus controller is needed. Toggling the `hidden` attribute instead of the class would be the native way to hide panels. Requested: toggle the `hidden` attribute (see HTML panels line).
               ↳ https://developer.mozilla.org/pt-BR/docs/Web/HTML/Reference/Global_attributes/hidden
  ⚠️ Spec       no role=tab/tablist/tabpanel, no aria-selected, no aria-controls/aria-labelledby
               shadcn: roles, aria-selected, tabpanel aria-labelledby present
               APG (recommendation): tab list has role tablist, each tab role tab with aria-selected, each panel role tabpanel labelled by its tab. RubyUI meets none; shadcn meets them. Requested: role tablist/tab/tabpanel, aria-selected, aria-controls + aria-labelledby with generated ids; keep every panel mounted so all tabs get aria-controls.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#roles_states_properties
  ⚠️ Spec       no aria-controls on any tab
               shadcn: aria-controls only on the active tab; inactive tabs have none (their panels are unmounted)
               APG: "Each element with role tab has the property aria-controls referring to its associated tabpanel". shadcn misses it on inactive tabs (Base UI keepMounted=false); RubyUI on all. If RubyUI fixes it, keeping panels mounted lets every tab carry aria-controls (follows the spec over shadcn). Requested: role tablist/tab/tabpanel, aria-selected, aria-controls + aria-labelledby with generated ids; keep every panel mounted so all tabs get aria-controls.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#roles_states_properties
  ⚠️ Spec       keyboard: no arrow-key navigation
               shadcn: arrows + Home/End per APG
               APG: Left/Right Arrow move focus between tabs with wrap; Tab into the list lands on the active tab. RubyUI meets neither. Requested: APG keyboard in the controller: roving tabindex, ←/→ with wrap, Home/End, manual activation; TabsContent tabindex=0.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#keyboard_interaction
this PR (after)
shadcn audit  ruby-ui/ruby_ui
  base 1998c5e8  head c88578e8  shadcn 8a353667
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme  neutral tokens match shadcn
Tabs  (ref: bases/base/ui/tabs.tsx + style-vega.css @ 8a353667)
  ⏸ Look       focus/light, focus/dark not reached on rubyui: trigger never received keyboard focus. Expected on both sides now: a roving tabindex never lets Tab land on the inactive trigger. Focus driven by hand: identical box-shadow/outline/border (screens tabs-focus-*-now).
  ⏸ Look       focus/light, focus/dark not reached on shadcn: trigger never received keyboard focus. Expected on both sides now: a roving tabindex never lets Tab land on the inactive trigger. Focus driven by hand: identical box-shadow/outline/border (screens tabs-focus-*-now).
  ⏸ Look       properties: font-family
               Tabs, TabsList, TabsTrigger, TabsContent — explained by environment diff: font. Only font-family's fallback list differs (both render Inter Variable); every size property now matches.
  ⏸ ARIA       focus/light, focus/dark not reached on rubyui: trigger never received keyboard focus. Expected on both sides now: a roving tabindex never lets Tab land on the inactive trigger. Focus driven by hand: identical box-shadow/outline/border (screens tabs-focus-*-now).
  ⏸ ARIA       focus/light, focus/dark not reached on shadcn: trigger never received keyboard focus. Expected on both sides now: a roving tabindex never lets Tab land on the inactive trigger. Focus driven by hand: identical box-shadow/outline/border (screens tabs-focus-*-now).
  ✅ ARIA       aria-controls: resolves — follows spec
               TabsTrigger — closed/light, open/light, after-esc/light, closed/dark, open/dark, after-esc/dark. Diverges from shadcn, follows the spec: APG asks every tab for aria-controls pointing at its tabpanel; Base UI unmounts inactive panels, so its inactive tabs carry none. RubyUI keeps panels mounted (audit/divergences/tabs.yml).
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#roles_states_properties
  ✅ ARIA       Tabs, TabsList, TabsContent: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       TabsTrigger focus-visible ring, hover color, active shadow
               Computed on keyboard focus and hover in Chromium: same box-shadow, outline, border and color on both sides.
               ↳ bases/base/ui/tabs.tsx:60
  ✅ Look       TabsList variant: :line (docs example)
               Classes ported verbatim (group-data variants spelled data-[...]); active ::after opacity 1, height 2px. Not in the fixture: checked on /docs/tabs (screens tabs-docs-line-*-now).
               ↳ bases/base/ui/tabs.tsx:31,63
  ✅ HTML       data-slot per part, data-orientation, data-active, data-disabled, data-variant
               Read from the rendered DOM. RubyUI also keeps data-value on tabs and panels.
               ↳ bases/base/ui/tabs.tsx:14-15,58
  ✅ HTML       every TabsContent mounted; inactive ones carry the hidden attribute — intentional
               Panels are server-rendered, and keeping them lets every tab point aria-controls at its panel, as the APG tabs pattern asks.
               ↳ audit/divergences/tabs.yml
  ✅ HTML       disabled tab: aria-disabled=true + data-disabled, focusable
               Arrow keys reach Billing on both sides; clicking it does nothing.
               ↳ bases/base/ui/tabs.tsx:57
  ✅ Props      Tabs(default_value:, orientation:), TabsList(variant:, activate_on_focus:, loop_focus:), TabsTrigger(value:, disabled:), TabsContent(value:)
               default: kept as an alias of default_value:; as: (TabsTrigger) is RubyUI-only. The docs' default_value: now works (it used to render a stray default-value attribute).
               ↳ bases/base/ui/tabs.tsx:9,30-31
  ✅ Props      no value:/on_value_change:/keep_mounted:; ruby-ui--tabs:change event + data-ruby-ui--tabs-active-value — intentional
               A server-rendered component has no React state to control: the Stimulus value attribute is the controlled input and the DOM event is the change callback.
               ↳ audit/divergences/tabs.yml
  ✅ Behavior   click, Enter and Space activate; disabled tab never activates
               Driven in Chromium on both fixtures.
               ↳ bases/base/ui/tabs.tsx:57
  ✅ Behavior   roving tabindex; ←/→ move focus with wrap, Home/End, manual activation; Tab goes to the tabpanel (tabindex=0), then out
               Same sequence as the audit (Tab, →×3, Enter, Home, End, ←, Space, Tab, Tab): identical focus, tabindex and active tab at every step. Vertical (↑/↓), activate_on_focus and loop_focus: false checked on /docs/tabs.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#keyboard_interaction
  ✅ Animation  transition-all 150ms on trigger; panel swaps instantly
               Computed transition on both sides.
               ↳ bases/base/ui/tabs.tsx:60
  ✅ Native     panels hidden with the hidden attribute; JS only for selection and keyboard
               HTML has no tabs control; the hidden attribute is the native way to hide inactive panels.
               ↳ https://developer.mozilla.org/pt-BR/docs/Web/HTML/Reference/Global_attributes/hidden
  ✅ Spec       role tablist/tab/tabpanel, aria-selected, aria-labelledby on panels, aria-controls on every tab — follows spec
               APG: "Each element with role tab has the property aria-controls referring to its associated tabpanel". Met by RubyUI on every tab; shadcn misses it on inactive tabs.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#roles_states_properties
  ✅ Spec       arrow keys with wrap, Home/End, Tab lands on the active tab — follows spec
               APG keyboard interaction for a horizontal (and vertical) tab list met.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#keyboard_interaction

Test steps

  1. cd gem && bundle exec rake, cd docs && bin/rails test, cd mcp && bundle exec rake test, cd audit && bundle exec rake.
  2. cd docs && bin/dev, open /docs/tabs.
  3. Tab into the first example: focus lands on Account. Arrows move focus with wrap, Home/End jump, Enter/Space activate, and Tab moves into the panel.
  4. Check the Line and Vertical examples (↑/↓ on the vertical one) and the Keyboard example (activation on focus, no wrap).
  5. With a screen reader or the accessibility tree: a tablist, tabs with selected state, and a tabpanel labelled by its tab.

🤖 Generated with Claude Code


Summary by cubic

Tabs now matches shadcn's Base UI build and the vega style, per the 2026-11-04 shadcn audit: vega classes on every part, proper tab semantics (tablist/tab/tabpanel, aria-selected, aria-controls, aria-labelledby), and APG keyboard behavior (roving tabindex, arrow keys with wrap, Home/End, manual activation). The controller also survives morphs and removed tabs: ids and ARIA wiring are restored on every sync, and removing the active tab hands selection to a remaining enabled one.

New Features

  • Tabs(orientation: :vertical), TabsList(variant: :line, activate_on_focus:, loop_focus:), Tabs(default_value:) (default: kept as an alias and the stray default-value attribute no longer renders), and a ruby-ui--tabs:change event with {value, previousValue}.
  • Setting data-ruby-ui--tabs-active-value switches tabs from outside; disabled triggers render aria-disabled + data-disabled and stay reachable with the arrow keys.

Breaking changes

  • Triggers no longer set data-state="active|inactive"; custom CSS using data-[state=active]: needs data-active: (or aria-selected:) instead.
  • TabsTrigger(disabled: true) renders aria-disabled instead of the native disabled attribute.
  • Inactive panels are hidden with the hidden attribute instead of the hidden class.
  • Panel text is text-sm and panel mt-2 is gone; spacing comes from the root's gap-2.