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

Merged
CI
2d ago
+625-254

Button now follows shadcn's Base UI build with the vega style. The shadcn audit finds no Look, HTML, Props, ARIA, Behavior or Animation differences; the one ⏸ left is the font stack, which differs between the docs app and the sandbox.

Button, closed, light
Button, closed, dark
Button, focus, light
Button, focus, dark

What changes

  • Base classes follow vega: a 1px transparent border with bg-clip-padding, text-sm on every size except :xs, px-2.5 with a gap, and SVG icons at 16px (12px on :xs) that ignore the pointer. An icon marked data-icon="inline-start" or "inline-end" gets tighter padding on its side. Text buttons end up about 10px narrower than before.
  • Focus shows border-ring with a 3px ring-ring/50 instead of a 1px solid ring. aria-invalid paints the border and ring in the destructive color, and aria-expanded keeps outline, ghost and secondary in their hover color (for buttons that open a menu or popover).
  • Primary and destructive lose their shadow, outline uses shadow-xs, and outline is tinted with input/30 in dark mode. Hovers follow vega. Secondary had no hover at all (hover:bg-opacity-80 doesn't exist in Tailwind 4) and destructive only had one on <a>.
  • Destructive is the soft vega button (bg-destructive/10 text-destructive) instead of solid red with white text.
  • transition-all and a 1px press-down on click (active:not-aria-[haspopup]:translate-y-px).
  • New size: :xs. Icon buttons are 24, 32, 36 and 40px for :xs, :sm, :md and :lg, as shadcn's icon-xs to icon-lg. size: :icon, :icon_sm and the rest work too; before this PR, size: :icon (used by SidebarTrigger and some docs) rendered with no size classes.
  • :default is an alias of :primary for variant and of :md for size.
  • data-slot="button" on the element, and data-disabled when disabled.
  • href: renders an <a role="button" tabindex="0">, as Base UI does with render={<a />}: screen readers announce a button, and the ruby-ui--button controller makes Space activate it like Enter. shadcn passes the element through the render prop; Phlex has no render prop, so the kwarg difference is registered in audit/divergences.yml. A disabled link drops its href.
  • focusable_when_disabled: (Base UI's focusableWhenDisabled) keeps a disabled button in the tab order: it renders aria-disabled instead of disabled. The ruby-ui--button controller blocks the click in the capture phase, so neither the caller's Stimulus actions (with or without :prevent/:stop) nor a form submission run. Enter and Space are blocked too, since they arrive as clicks. As in shadcn, only the disabled attribute dims a button, so this one keeps its enabled look.
  • Codeblock's copy button uses size: :xs and a size-4 icon, so it stays 24px.
  • Docs: Sizes, Icon, With Icon, Loading, As Link, Focusable When Disabled and Invalid examples, with the icons inline so the snippets work when copied.
  • Button joins the shadcn audit (map entry, fixtures covering every variant, size, an icon button, a link and a focusable disabled button). The audit CLI test that used button as its unmapped example now uses shortcut_key.

Buttons look different across the docs and in apps that copied the component: narrower, no shadow, a soft destructive, and icon: true, size: :sm grows from 24px to 32px. Pagination items reuse Button's classes and pick up the new padding.

How to test

  • cd gem && bundle exec rake
  • cd docs && bin/dev, open /docs/button and check each example in light and dark. Tab to the buttons to see the focus ring, and try Enter, Space and click on the Focusable When Disabled button: it takes focus and nothing happens.
  • cd audit and run the shadcn audit for button (/shadcn-audit button in Claude Code).
shadcn audit on main
shadcn audit  ruby-ui/ruby_ui
  base 27edd81c  head 27edd81c  shadcn c0d63c33
⚠️ 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)
⚠️ Environment  RubyUI preview: throwaway docs/Gemfile.lock bumped date 3.5.1 -> 3.6.0 so Rails boots (the machine's rdoc 8.1.0 RubyGems plugin activates date 3.6.0 before Bundler).
✅ Theme  neutral tokens match shadcn
Button  (ref: bases/base/ui/button.tsx + style-vega.css @ c0d63c33)
  ❌ Look       background-color: oklab(0.704 0.176821 0.072217 / 0.6); background-color: oklch(0.145 0 0); background-color: oklch(0.577 0.245 27.325); 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, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(0.556 0 0) 0px 0px 0px 1px, 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, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(0.708 0 0) 0px 0px 0px 1px, 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, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; color: rgb(255, 255, 255); column-gap: normal; gap: normal; padding-bottom: 6px; padding-bottom: 8px; padding-left: 12px; padding-left: 16px; padding-right: 12px; padding-right: 16px; padding-top: 6px; padding-top: 8px; row-gap: normal
               vega: background-color: oklab(0.577 0.217662 0.112464 / 0.1); background-color: oklab(0.704 0.176821 0.072217 / 0.2); background-color: oklab(1 0 0 / 0.045); border-bottom-color: oklch(0.556 0 0); border-bottom-color: oklch(0.708 0 0); border-bottom-color: rgba(0, 0, 0, 0); border-bottom-width: 1px; border-left-color: oklch(0.556 0 0); border-left-color: oklch(0.708 0 0); border-left-color: rgba(0, 0, 0, 0); border-left-width: 1px; border-right-color: oklch(0.556 0 0); border-right-color: oklch(0.708 0 0); border-right-color: rgba(0, 0, 0, 0); border-right-width: 1px; border-top-color: oklch(0.556 0 0); border-top-color: oklch(0.708 0 0); border-top-color: rgba(0, 0, 0, 0); border-top-width: 1px; box-shadow: none; 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) 0px 0px 0px 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) 0px 0px 0px 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, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; color: oklch(0.577 0.245 27.325); color: oklch(0.704 0.191 22.216); column-gap: 4px; column-gap: 6px; gap: 4px; gap: 6px; padding-bottom: 0px; padding-left: 10px; padding-left: 8px; padding-right: 10px; padding-top: 0px; row-gap: 4px; row-gap: 6px
               Button — closed/light, focus/light, closed/dark, focus/dark. Grouped by root cause in the Look lines below. Requested: align with vega (items 1-7).
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       properties: font-family, font-size, line-height, width
               Button — explained by environment diff: font. Only font-family is environmental; the font-size gap on sm/lg/icon is a real difference, graded below. Kept as is: environment font stack, not a Button difference.
  ✅ ARIA       Button: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ❌ Look       no border (border-width 0)
               vega: border border-transparent bg-clip-padding (1px transparent border on every variant)
               Base box. Outline/icon keep the 1px border on both sides. Requested: align base classes with vega.
               ↳ styles/style-vega.css:145
  ❌ Look       md px-4 py-2, sm px-3 py-1.5, lg px-4 py-2; no gap (gap: normal)
               vega: default px-2.5 gap-1.5, sm px-2.5 gap-1, lg px-2.5 gap-1.5; has-data-[icon=inline-start]:pl-2 / inline-end:pr-2
               Every text button renders ~10px wider in RubyUI (Button 75.8px vs 65.8px). Heights match (36/32/40px). Requested: vega padding, gap and data-icon padding per size.
               ↳ styles/style-vega.css:177
  ❌ Look       sm text-xs (12px), lg/xl text-base (16px), icon-only no text size (16px)
               vega: text-sm (14px) on every size except xs (text-xs)
               Measured: Small 12 vs 14px, Large 16 vs 14px, icon 16 vs 14px. Requested: text-sm on every size (text-xs on :xs).
               ↳ styles/style-vega.css:145
  ❌ Look       default/destructive/sizes shadow (0 1px 3px + 0 1px 2px -1px); outline shadow-sm
               vega: default/destructive no shadow; outline shadow-xs (0 1px 2px / 0.05)
               Requested: drop shadow on default/destructive, shadow-xs on outline.
               ↳ styles/style-vega.css:149
  ❌ Look       destructive: bg-destructive text-white shadow-sm, dark:bg-destructive/60
               vega: destructive: bg-destructive/10 text-destructive, dark:bg-destructive/20 (soft tinted button)
               Biggest visual gap: RubyUI is a solid red button, shadcn vega a pale red one with red text. Requested: vega soft destructive.
               ↳ styles/style-vega.css:165
  ❌ Look       outline: border-input bg-background in dark too
               vega: outline: border-border bg-background, dark:bg-input/30 dark:border-input
               Dark only: background oklch(0.145 0 0) vs oklab(1 0 0 / 0.045). Requested: border-border bg-background dark:bg-input/30 dark:border-input.
               ↳ styles/style-vega.css:153
  ❌ Look       focus-visible:ring-1 ring-ring (1px solid ring, border unchanged)
               vega: focus-visible:border-ring ring-3 ring-ring/50 (3px half-transparent ring + ring-colored border)
               Seen in the focus state, light and dark. Requested: focus-visible:border-ring ring-3 ring-ring/50.
               ↳ styles/style-vega.css:145
  ❌ Look       hover: default bg-primary/90; outline/ghost bg-accent text-accent-foreground; secondary hover:bg-opacity-80 (no rule in Tailwind 4: no hover at all); destructive [a&]:hover only (no hover on <button>)
               vega: hover: default bg-primary/80; outline/ghost bg-muted text-foreground (ghost dark:bg-muted/50); secondary bg-[color-mix(secondary, foreground 5%)]; destructive bg-destructive/20
               Read from the matched :hover rules resolved in the browser. Secondary and destructive buttons give no hover feedback in RubyUI. Requested: vega hovers on every variant (fixes secondary and destructive having none).
               ↳ styles/style-vega.css:149
  ❌ Look       no aria-invalid or aria-expanded styles
               vega: aria-invalid:border-destructive ring-3 ring-destructive/20 (dark /40, border /50); outline/ghost/secondary aria-expanded:bg-muted/bg-secondary
               aria-invalid verified in the browser (shadcn border turns destructive, RubyUI unchanged); aria-expanded from source. Requested: add aria-invalid and aria-expanded styles.
               ↳ styles/style-vega.css:145
  ❌ Look       no svg rules: a 24px lucide icon renders at 24px and catches pointer events
               vega: [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 (size-3 on xs)
               Measured: icon 24px vs 16px in both the icon button and the Send button. Requested: vega svg rules.
               ↳ styles/style-vega.css:145
  ✅ Look       radius 8px, heights 36/32/40px, icon 36x36, font-weight 500, disabled opacity 0.5, link underline-offset-4 + hover:underline
               ↳ styles/style-vega.css:145
  ❌ HTML       <button type="button"> with no data-slot; disabled renders only disabled
               shadcn: <button type="button" data-slot="button">; disabled adds data-disabled
               Element and type match. data-slot is what other parts (button-group, input-group) target with in-data-[slot=button]. Requested: data-slot="button" always, data-disabled when disabled.
               ↳ bases/base/ui/button.tsx:43
  ❌ Props      size: :sm, :md, :lg, :xl; icon: true reuses them (sm = 24px square)
               shadcn: size: default, xs, sm, lg, icon, icon-xs, icon-sm, icon-lg (icon-xs 24px, icon-sm 32px)
               No xs and no icon-xs; icon: true + size: :sm gives 24px where shadcn icon-sm is 32px. Naming: shadcn default variant = :primary, default size = :md. rubyui_only: type, icon, size :xl. Requested: add size :xs; icon: true sizes xs 24 / sm 32 / md 36 / lg 40; accept :default as alias for variant :primary and size :md.
               ↳ bases/base/ui/button.tsx:22
  ❌ Props      always renders <button>; aria-disabled only restyles
               shadcn: Base UI render (e.g. <a>), focusableWhenDisabled (keeps focus, uses aria-disabled)
               Inherited from @base-ui/react 1.9.0 Button.d.ts. A link that looks like a button needs another component today. Requested: add href: (renders <a href>) and focusable_when_disabled: (aria-disabled instead of disabled, click blocked).
               ↳ https://base-ui.com/react/components/button
  ✅ Behavior   native <button>: Tab focus, Enter and Space fire click, disabled ignores click and leaves tab order
               Driven in the browser on both sides.
               ↳ bases/base/ui/button.tsx:41
  ⚠️ Animation  transition-colors; no press feedback
               shadcn: transition-all; active:not-aria-[haspopup]:translate-y-px (1px press-down)
               Read from the resolved :active rules; shadcn's transition-all also animates the focus ring and border. Requested: transition-all + active:not-aria-[haspopup]:translate-y-px.
               ↳ styles/style-vega.css:145
  ✅ Native     native <button>, no JS — native
               ↳ https://developer.mozilla.org/pt-BR/docs/Web/HTML/Element/button
  ✅ Spec       -
               No violation found on the shadcn side.
               ↳ https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element
shadcn audit on this PR
shadcn audit  ruby-ui/ruby_ui
  base 27edd81c  head 27edd81c  shadcn c0d63c33
⚠️ 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)
⚠️ Environment  RubyUI preview: throwaway docs/Gemfile.lock bumped date 3.5.1 -> 3.6.0 so Rails boots (the machine's rdoc 8.1.0 RubyGems plugin activates date 3.6.0 before Bundler).
⚠️ Environment  RubyUI 'after' preview: main checkout with this branch's files copied over (no commit yet), served on 3183.
✅ Theme  neutral tokens match shadcn
Button  (ref: bases/base/ui/button.tsx + style-vega.css @ c0d63c33)
  ⏸ Look       properties: font-family
               Button — explained by environment diff: font
  ✅ ARIA       Button: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       vega base: border-transparent bg-clip-padding, text-sm, px-2.5 + gap per size, svg rules, no shadow (outline shadow-xs), soft destructive, outline dark:bg-input/30, focus border-ring ring-3 ring-ring/50
               ↳ styles/style-vega.css:145
  ✅ Look       hover: primary /80, outline/ghost bg-muted text-foreground, secondary color-mix 5%, destructive /20; active translate-y-px
               Checked in the browser; the secondary color-mix rule read straight from the stylesheet.
               ↳ styles/style-vega.css:149
  ✅ Look       aria-invalid: border-destructive + ring-3 ring-destructive/20; aria-expanded on outline/ghost/secondary
               aria-invalid verified in the browser on both sides.
               ↳ styles/style-vega.css:145
  ✅ HTML       <button type="button" data-slot="button">, data-disabled when disabled
               ↳ bases/base/ui/button.tsx:43
  ✅ Props      size: :xs, :sm, :md, :lg (+ :xl); icon: true or size: :icon/:icon_xs/:icon_sm/:icon_lg → 24/32/36/40px; :default aliases :primary and :md
               rubyui_only: type, icon, size :xl.
               ↳ bases/base/ui/button.tsx:22
  ✅ Props      focusable_when_disabled: aria-disabled + data-disabled, stays in tab order, Enter/Space/click blocked by ruby-ui--button
               Driven in the browser on /docs/button: focused, 0 clicks reached a document listener.
               ↳ https://base-ui.com/react/components/button
  ✅ Props      href: renders <a href data-slot="button"> — intentional
               Phlex has no render prop; a link is the only other element a button needs, and href: matches Badge, Link and PaginationItem.
               ↳ audit/divergences.yml
  ✅ Behavior   native <button>: Tab focus, Enter and Space fire click, disabled ignores click and leaves tab order
               Unchanged by this fix.
               ↳ bases/base/ui/button.tsx:41
  ✅ Animation  transition-all; active:not-aria-[haspopup]:translate-y-px
               ↳ styles/style-vega.css:145
  ✅ Native     native <button>; JS only for focusable_when_disabled, which has no native equivalent (disabled removes focus) — native
               ↳ https://developer.mozilla.org/pt-BR/docs/Web/HTML/Element/button
  ✅ Spec       -
               No violation found on the shadcn side.
               ↳ https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element

🤖 Generated with Claude Code