[Feature] Toggle: match shadcn (Base UI + vega)
Brings RubyUI::Toggle in line with shadcn's Base UI Toggle and the vega style. The ToggleGroup PR builds on this one, so every fix from the toggle_group audit that lives in RubyUI::Toggle is here too.
Changes
- Vega classes:
gap-1,px-2.5on sm, default and lg,hover:bg-muted hover:text-foregroundon both variants (the default variant used to fade its text on hover),aria-pressed:bg-mutedfor the pressed state,dark:aria-invalid:ring-destructive/40, andhas-data-[icon=inline-start|end]padding per size, the same convention Button uses. - The button now has
data-slot="toggle",data-pressedanddata-disabled, as in Base UI.data-stateis gone, and the controller keepsdata-pressedin sync. data-[state=on]:bg-mutedstays in Toggle's classes for ToggleGroupItem: in single mode it is stillrole=radio+aria-checkedwithdata-state, whicharia-pressed:doesn't reach. Keeping it in Toggle means an app that regenerates only Toggle keeps its existing ToggleGroup's pressed style.- Docs: a With Icon example (aria-label for icon-only,
data-iconnext to text). - Audit: map, fixtures, divergences (span wrapper,
onPressedChangeas theruby-ui--toggle:changeevent,valueas the form value) and baseline at shadcn8a353667. MCP registry rebuilt.
Breaking for custom styles
Classes that targeted data-[state=on] / data-[state=off] on a standalone Toggle need to move to aria-pressed: / aria-[pressed=false]: (or data-pressed: / not-data-pressed:). ToggleGroupItem still renders data-state until the ToggleGroup PR.
Before / after
The "main" row is the audit fixture before this PR; the Underline toggle (with data-icon) was added to the fixture afterwards.





shadcn audit on main
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
Toggle (ref: bases/base/ui/toggle.tsx + style-vega.css @ 8a353667)
❌ Look color: oklch(0.205 0 0); column-gap: 8px; gap: 8px; padding-left: 6px; padding-left: 8px; padding-right: 6px; padding-right: 8px; row-gap: 8px
vega: color: oklch(0.145 0 0); column-gap: 4px; gap: 4px; padding-left: 10px; padding-right: 10px; row-gap: 4px
Toggle — closed/focus, light/dark. Two root causes: (1) gap-2 in RubyUI BASE_CLASSES vs vega .cn-toggle gap-1 (8px vs 4px), all toggles; (2) padding: size default px-2 vs px-2.5 (8px vs 10px), size sm px-1.5 vs px-2.5 (6px vs 10px), lg px-2.5 matches; sm/default/text toggles 4–8px narrower. Pressed toggles [1],[3] light: color oklch(0.205) (data-[state=on]:text-accent-foreground) vs oklch(0.145) inherited foreground; vega only sets aria-pressed:bg-muted (bg identical in neutral, accent = muted). Requested: use gap-1 and px-2.5 for sm/default/lg in toggle.rb; color part via the pressed/hover decision below. Requested: run `baseline toggle 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR.
↳ style-vega.css:1343, style-vega.css:1358-1368
⏸ Look properties: font-family, width
font-family stack differs only by fallbacks after Inter Variable (both render Inter); width diffs are the padding line above. Kept as is: environment font fallbacks; width resolves with the padding fix.
✅ ARIA Toggle: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
❌ Look hover:text-muted-foreground (default) / hover:bg-accent hover:text-accent-foreground (outline); hover colors: default oklch(0.556) light, oklch(0.708) dark; outline oklch(0.205) light
vega: hover:bg-muted hover:text-foreground (all variants); hover color oklch(0.145) light, oklch(0.985) dark
Hover state, measured with Playwright (mouse over each toggle). Default variant text gets lighter on hover in RubyUI (muted-foreground) where shadcn keeps foreground; hovering a pressed toggle keeps accent-foreground in RubyUI. Screens: toggle-hover-*.png (Italic outline hovered). Requested: aria-pressed:bg-muted instead of data-[state=on]:bg-accent/text-accent-foreground; hover:bg-muted hover:text-foreground for every variant (drop outline hover:bg-accent hover:text-accent-foreground).
↳ style-vega.css:1343, style-vega.css:1355
❌ Look no dark:aria-invalid:ring-destructive/40 (dark focus ring on invalid: destructive / 0.2)
vega: dark:aria-invalid:ring-destructive/40 (dark focus ring on invalid: destructive / 0.4)
Measured: focused aria-invalid toggle, dark theme, box-shadow ring alpha 0.2 vs 0.4. Light matches. Requested: add dark:aria-invalid:ring-destructive/40 to BASE_CLASSES.
↳ style-vega.css:1343
❌ Look no has-data-[icon=inline-start]/[icon=inline-end] padding rules
vega: size default/lg: has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2; sm: pr-1.5/pl-1.5
From source only: the fixture's icons carry no data-icon, so the rule wasn't exercised in the browser. Same convention RubyUI Button already follows (data-icon="inline-start"). Requested: add has-data-[icon=inline-start/end] padding per size, plus a data-icon toggle in the fixture.
↳ style-vega.css:1358-1368
❌ HTML <span class="contents" data-controller="ruby-ui--toggle"><button type="button" aria-pressed data-state="on|off">…</button>[<input type="hidden">]</span>; no data-slot, no data-pressed, no data-disabled
shadcn: <button type="button" aria-pressed data-slot="toggle" [data-pressed] [data-disabled]>…</button>
Rendered DOM on both sides. Missing data-slot="toggle" and Base UI state attributes (data-pressed, data-disabled); RubyUI uses data-state=on/off instead, which also drives its pressed style. The display:contents span wrapper (Stimulus controller + optional hidden input for name:) has no shadcn counterpart; it doesn't affect layout or the a11y tree. Requested: add data-slot="toggle", data-pressed and data-disabled (controller keeps data-pressed in sync), drop data-state. Suggested divergence: the display:contents span wrapper (HTML).
↳ bases/base/ui/toggle.tsx:37
✅ Props variant, size, pressed, disabled
pressed and defaultPressed both map to pressed: (server-rendered, no controlled mode). rubyui_only: name, value, unpressed_value (hidden form input; Base UI Toggle doesn't submit forms), wrapper.
↳ bases/base/ui/toggle.tsx:29-33
❌ Props no onPressedChange kwarg (controller dispatches ruby-ui--toggle:change with detail.pressed); no ToggleGroup value
shadcn: onPressedChange, value (Base UI Toggle.Props, @base-ui/react 1.9.0)
onPressedChange: RubyUI's equivalent is the bubbling ruby-ui--toggle:change DOM event. value: identifies the toggle inside a ToggleGroup in Base UI; RubyUI's value: is the hidden input's form value, a different thing. Suggested divergence: onPressedChange → ruby-ui--toggle:change event; value → RubyUI value: is the form value (Props).
↳ .shadcn-audit/sandbox/node_modules/@base-ui/react/toggle (Toggle.Props)
✅ Behavior click, Space and Enter flip aria-pressed; disabled click ignored; Tab order identical
Driven in headless Chromium 1280×800 on both fixtures: click on first toggle false→true, Space →false, Enter →true; click on disabled toggle stays false.
↳ bases/base/ui/toggle.tsx:37
✅ Animation transition: color, box-shadow 0.15s cubic-bezier(0.4,0,0.2,1)
↳ style-vega.css:1343
✅ Native native <button type="button"> + aria-pressed; JS only flips state — native
No native toggle-button element; <input type=checkbox> would change the role. Native disabled used.
↳ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed
✅ Spec button with aria-pressed true/false, label constant across states — follows spec
Toggle button pattern: role button, aria-pressed, label doesn't change when pressed. Both sides comply (icon-only toggles use aria-label).
↳ https://www.w3.org/WAI/ARIA/apg/patterns/button/#wai-ariaroles,states,andproperties
shadcn audit on this PR
shadcn audit ruby-ui/ruby_ui
base 1998c5e8 head 4cb15704 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
Toggle (ref: bases/base/ui/toggle.tsx + style-vega.css @ 8a353667)
⏸ Look properties: font-family
Font stack differs only in fallbacks after Inter Variable (both render Inter); no width differences remain.
✅ ARIA Toggle: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
✅ Look gap-1; px-2.5 (sm/default/lg); aria-pressed:bg-muted; hover:bg-muted hover:text-foreground; dark:aria-invalid:ring-destructive/40; has-data-[icon=inline-start|end] padding
Computed styles equal on all 10 toggles in closed/focus, light/dark (collect), plus hover on every toggle and the focused invalid toggle in dark (Playwright). The data-icon toggle (Underline) gets pl-2 on both sides.
↳ style-vega.css:1342-1368
✅ HTML <button type=button aria-pressed data-slot=toggle [data-pressed] [data-disabled]> inside <span class=contents> (controller + hidden input) — intentional
Button attributes match Base UI; data-state removed. The display:contents wrapper is a registered divergence.
↳ audit/divergences/toggle.yml
✅ Props variant, size, pressed, disabled
rubyui_only: name, value, unpressed_value, wrapper.
↳ bases/base/ui/toggle.tsx:29-33
✅ Props ruby-ui--toggle:change event; value: is the form value — intentional
Registered divergences.
↳ audit/divergences/toggle.yml
✅ Behavior click, Space and Enter flip aria-pressed and data-pressed; disabled click ignored; Tab order identical
Driven in headless Chromium 1280×800 on both fixtures.
↳ bases/base/ui/toggle.tsx:37
✅ Animation transition: color, box-shadow 0.15s
↳ style-vega.css:1343
✅ Native native <button type="button"> + aria-pressed; JS only flips state — native
No native toggle-button element; <input type=checkbox> would change the role. Native disabled used.
↳ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed
✅ Spec button with aria-pressed true/false, label constant across states — follows spec
Toggle button pattern: role button, aria-pressed, label doesn't change when pressed. Both sides comply (icon-only toggles use aria-label).
↳ https://www.w3.org/WAI/ARIA/apg/patterns/button/#wai-ariaroles,states,andproperties
Test steps
cd gem && bundle exec rake(Toggle and ToggleGroup tests, StandardRB)cd audit && bundle exec rake;cd mcp && bundle exec rake test;cd docs && bin/rails test/docs/toggle: click and press Space/Enter on a toggle;aria-pressedanddata-pressedflip together and the pressed background isbg-muted. Hover keeps the text at the foreground color. On/docs/toggle_group, the pressed item in a single group keeps its background.
Follow-ups
- The Stimulus controller has no JS test (the gem has no JS test runner); the click/Space/Enter cycle was checked in the browser during the audit.
- ToggleGroup PR: move single mode to
aria-pressed, then dropdata-[state=on]:bg-mutedfrom Toggle.
🤖 Generated with Claude Code
Summary by cubic
Brings RubyUI::Toggle in line with shadcn's Base UI Toggle and the vega style. The button now uses gap-1 and px-2.5 on sm/default/lg sizes, aria-pressed:bg-muted for the pressed state, hover:bg-muted hover:text-foreground on both variants (the default variant no longer fades its text on hover), dark:aria-invalid:ring-destructive/40, and has-data-[icon=inline-start|end] padding per size. It also carries data-slot="toggle", data-pressed, and data-disabled instead of data-state, with the Stimulus controller keeping data-pressed in sync. data-[state=on]:bg-muted stays in place so ToggleGroupItem in single mode keeps its pressed background. Adds audit fixtures, component map, divergences, a With Icon docs example, and rebuilds the MCP registry.
Breaking for custom styles
- Classes targeting
data-[state=on]ordata-[state=off]on a standalone Toggle must move toaria-pressed:/aria-[pressed=false]:(ordata-pressed:/not-data-pressed:). ToggleGroupItem still rendersdata-stateuntil the ToggleGroup PR.