[Feature] Tabs: match shadcn (Base UI + vega)
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):

After clicking Password (light):

Keyboard focus (light):

Closed (dark):

After clicking Password (dark):

Keyboard focus (dark):

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

What changed
- Vega classes on every part. The root is a flex column with
gap-2, the list usesp-[3px], and the trigger has a transparent border,px-2, thering-[3px]focus ring, a hover color andbg-input/30with a border when active in dark mode. Panels aretext-sm. roletablist/tab/tabpanel,aria-selected,aria-controlson every tab andaria-labelledbyon 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-disabledand stay reachable with the arrow keys, as in Base UI. - Parts carry
data-slot,data-orientation,data-activeanddata-variant, as shadcn renders them. - New options:
Tabs(orientation: :vertical),TabsList(variant: :line, activate_on_focus:, loop_focus:), and aruby-ui--tabs:changeevent with{value, previousValue}. Tabsnow takesdefault_value:, shadcn'sdefaultValue. The docs and the home page already passed it, but the component ignored it and rendered a straydefault-valueattribute.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
hiddenattribute. Base UI unmounts inactive panels, which leaves inactive tabs withoutaria-controls. Keeping them follows the APG. - There's no
value/onValueChange/keepMounted. Settingdata-ruby-ui--tabs-active-valueswitches tabs, and thechangeevent reports the switch.
Breaking changes
- The controller no longer sets
data-state="active|inactive"on triggers. Custom CSS that usesdata-[state=active]:needsdata-active:(oraria-selected:) instead. TabsTrigger(disabled: true)now rendersaria-disabledinstead of the nativedisabledattribute.- Inactive panels are hidden with the
hiddenattribute instead of thehiddenclass. - Panel text is
text-smand the panel'smt-2is gone. The spacing now comes from the root'sgap-2.
Follow-ups
- The panel is focusable (
tabindex=0), but it hasoutline-noneand no ring, as in shadcn. WCAG 2.4.7 asks for a visible focus indicator. This could be a registered divergence with afocus-visiblering. (Codex review) - With nested tabs,
group-data-[orientation=…]/tabs:matches anyTabsancestor. A horizontal list inside vertical tabs would pick up the vertical styles. shadcn has the same issue. Keying the styles on each element's owndata-orientationwould 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
cd gem && bundle exec rake,cd docs && bin/rails test,cd mcp && bundle exec rake test,cd audit && bundle exec rake.cd docs && bin/dev, open /docs/tabs.- 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.
- Check the Line and Vertical examples (↑/↓ on the vertical one) and the Keyboard example (activation on focus, no wrap).
- 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 straydefault-valueattribute no longer renders), and aruby-ui--tabs:changeevent with{value, previousValue}.- Setting
data-ruby-ui--tabs-active-valueswitches tabs from outside; disabled triggers renderaria-disabled+data-disabledand stay reachable with the arrow keys.
Breaking changes
- Triggers no longer set
data-state="active|inactive"; custom CSS usingdata-[state=active]:needsdata-active:(oraria-selected:) instead. TabsTrigger(disabled: true)rendersaria-disabledinstead of the nativedisabledattribute.- Inactive panels are hidden with the
hiddenattribute instead of thehiddenclass. - Panel text is
text-smand panelmt-2is gone; spacing comes from the root'sgap-2.