[Feature] Button: match shadcn (Base UI + vega)
Merged
CI
2d ago+625+625+012345678901234567890123456789-254-254-012345678901234567890123456789
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.




What changes
- Base classes follow vega: a 1px transparent border with
bg-clip-padding,text-smon every size except:xs,px-2.5with a gap, and SVG icons at 16px (12px on:xs) that ignore the pointer. An icon markeddata-icon="inline-start"or"inline-end"gets tighter padding on its side. Text buttons end up about 10px narrower than before. - Focus shows
border-ringwith a 3pxring-ring/50instead of a 1px solid ring.aria-invalidpaints the border and ring in the destructive color, andaria-expandedkeeps 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 withinput/30in dark mode. Hovers follow vega. Secondary had no hover at all (hover:bg-opacity-80doesn'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-alland 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,:mdand:lg, as shadcn'sicon-xstoicon-lg.size: :icon,:icon_smand the rest work too; before this PR,size: :icon(used by SidebarTrigger and some docs) rendered with no size classes. :defaultis an alias of:primaryforvariantand of:mdforsize.data-slot="button"on the element, anddata-disabledwhen disabled.href:renders an<a role="button" tabindex="0">, as Base UI does withrender={<a />}: screen readers announce a button, and theruby-ui--buttoncontroller makes Space activate it like Enter. shadcn passes the element through therenderprop; Phlex has no render prop, so the kwarg difference is registered inaudit/divergences.yml. A disabled link drops itshref.focusable_when_disabled:(Base UI'sfocusableWhenDisabled) keeps a disabled button in the tab order: it rendersaria-disabledinstead ofdisabled. Theruby-ui--buttoncontroller 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 thedisabledattribute dims a button, so this one keeps its enabled look.- Codeblock's copy button uses
size: :xsand asize-4icon, 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
buttonas its unmapped example now usesshortcut_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 rakecd docs && bin/dev, open/docs/buttonand 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 auditand run the shadcn audit forbutton(/shadcn-audit buttonin 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