Skip to content

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

Merged
cirdes merged 5 commits into
mainfrom
tabs-match-shadcn
Nov 4, 2026
Merged

cirdes merged 5 commits into
mainfrom
tabs-match-shadcn

Conversation

@cirdes

@cirdes cirdes commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Written for commit 9796e16. Summary will update on new commits.

View guided diff Turn on auto-fix

cirdes and others added 3 commits November 4, 2026 11:54
- Vega classes on every part: flex column root with gap-2, p-[3px] list,
  trigger with border, ring-[3px] focus ring, hover color and dark
  bg-input/30; text-sm panel.
- Tab semantics: role tablist/tab/tabpanel, aria-selected, aria-controls
  on every tab and aria-labelledby on every panel. Panels stay mounted
  and hide with the hidden attribute.
- Keyboard as Base UI: roving tabindex, arrow keys with wrap, Home/End,
  manual activation, focusable panel.
- Disabled tabs use aria-disabled + data-disabled and stay reachable.
- data-slot, data-orientation, data-active, data-variant as in shadcn.
- New: Tabs(orientation:), TabsList(variant: :line, activate_on_focus:,
  loop_focus:), and a ruby-ui--tabs:change event.
- Tabs takes default_value: (shadcn's defaultValue). The docs already
  passed it, but it rendered a stray default-value attribute and was
  ignored. default: stays as an alias.
- Audit map, fixtures, divergences and baseline for tabs; MCP registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Removing the active tab (or its panel) hands selection to a remaining
  enabled tab, so the list never ends up with no tabbable tab.
- Each sync re-wires ids and aria-controls/aria-labelledby, so a morph
  that drops them gets them back.
- Generated ids use a counter, so values like "a/b" and "a-b" no longer
  collide on one id.
- Leaving the tab list without activating puts the tab stop back on the
  selected tab, so Tab back in lands there (APG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 15 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread gem/test/ruby_ui/tabs_test.rb Resolved
Comment thread gem/lib/ruby_ui/tabs/tabs.rb Resolved
Comment thread mcp/data/registry/components/tabs.json Resolved
Comment thread gem/lib/ruby_ui/tabs/tabs_controller.js Resolved
Comment thread gem/lib/ruby_ui/tabs/tabs_controller.js Resolved
Comment thread audit/fixtures/tabs/rubyui.rb Resolved
cirdes and others added 2 commits November 4, 2026 12:12
- The root's flex direction follows data-orientation, which the
  controller keeps in sync, so a runtime orientation change re-lays out.
- Tabs and panels drop aria-controls/aria-labelledby when their
  counterpart is removed.
- Generated ids survive reconnects: the base and counter persist and
  ids already in the document are skipped.
- The audit fixture uses default_value:; a test name matches what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 15 files

View guided diff | Turn on auto-fix | Re-trigger cubic

@cirdes
cirdes merged commit 39152ce into main Nov 4, 2026
10 checks passed
@cirdes
cirdes deleted the tabs-match-shadcn branch November 4, 2026 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant