Button
Displays a button or a component that looks like a button.
Usage
Default
Button { "Button" }
Outline
Button(variant: :outline) { "Outline" }
Secondary
Button(variant: :secondary) { "Secondary" }
Ghost
Button(variant: :ghost) { "Ghost" }
Destructive
Button(variant: :destructive) { "Destructive" }
Link
Button(variant: :link) { "Link" }
Sizes
size: :xs, :sm, :md (the default), :lg and :xl.
div(class: "flex flex-wrap items-center gap-2 justify-center") do
Button(variant: :outline, size: :xs) { "Extra Small" }
Button(variant: :outline, size: :sm) { "Small" }
Button(variant: :outline) { "Default" }
Button(variant: :outline, size: :lg) { "Large" }
Button(variant: :outline, size: :xl) { "Extra Large" }
end
Icon
icon: true makes a square button of the given size. Give it an aria-label.
div(class: "flex flex-wrap items-center gap-2 justify-center") do
[:xs, :sm, :md, :lg, :xl].each do |size|
Button(variant: :outline, size: size, icon: true, aria: {label: "Next"}) do
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true") do |s|
s.path(d: "m9 18 6-6-6-6")
end
end
end
end
With icon
Mark the icon with data-icon="inline-start" or "inline-end" so the button tightens the padding on that side. Icons are sized to 16px (12px on :xs) unless they carry a size-* class.
div(class: "flex flex-wrap items-center gap-2 justify-center") do
Button(variant: :outline) do
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true", data: {icon: "inline-start"}) do |s|
s.path(d: "M6 3v12")
s.circle(cx: "18", cy: "6", r: "3")
s.circle(cx: "6", cy: "18", r: "3")
s.path(d: "M18 9a9 9 0 0 1-9 9")
end
plain "New Branch"
end
Button do
plain "Send"
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true", data: {icon: "inline-end"}) do |s|
s.path(d: "m22 2-7 20-4-9-9-4Z")
s.path(d: "M22 2 11 13")
end
end
end
Loading
Button(variant: :outline, disabled: true) do
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true", class: "animate-spin", data: {icon: "inline-start"}) do |s|
s.path(d: "M21 12a9 9 0 1 1-6.219-8.56")
end
plain "Please wait"
end
As link
Pass href: to render an <a>. As with shadcn's render={<a />}, it carries role="button": screen readers announce a button, and Space activates it like Enter.
Button(href: "#") { "Login" }
Disabled
Button(disabled: true) { "Disabled" }
Focusable when disabled
focusable_when_disabled: true keeps a disabled button in the tab order (aria-disabled instead of disabled), for example so a tooltip can explain why. Clicks and keyboard activation are blocked. As in shadcn, only the disabled attribute dims a button, so this one keeps its enabled look.
Button(disabled: true, focusable_when_disabled: true) { "Focusable" }
Invalid
aria-invalid paints the border and focus ring in the destructive color.
Button(variant: :outline, aria: {invalid: "true"}) { "Pick a date" }
Submit
Button(type: :submit) { "Submit application" }
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Button
Manual installation
1
Add RubyUI::Button to app/components/ruby_ui/button/button.rb
# frozen_string_literal: true
module RubyUI
# A button, styled after shadcn's Button (Base UI + vega). Renders a
# <button>, or an <a role="button"> when given href: (shadcn's
# render={<a />}).
#
# icon: true makes a square icon button of the given size (shadcn's
# size="icon", "icon-sm"…); size: :icon, :icon_sm… work too. An icon marked
# data-icon="inline-start" or "inline-end" gets tighter padding on its side.
#
# focusable_when_disabled: true (with disabled: true) keeps the button in the
# tab order: it renders aria-disabled instead of disabled and blocks clicks.
# As in shadcn, only the disabled attribute dims the button, so it keeps its
# enabled look.
class Button < Base
BASE = [
"group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap",
"rounded-md border border-transparent bg-clip-padding text-sm font-medium",
"transition-all outline-none select-none active:not-aria-[haspopup]:translate-y-px",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
"dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
].freeze
# :primary is shadcn's default variant.
VARIANTS = {
primary: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground " \
"aria-expanded:bg-muted aria-expanded:text-foreground " \
"dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground " \
"hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] " \
"aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 " \
"aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 " \
"focus-visible:border-destructive/40 focus-visible:ring-destructive/20 " \
"dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline"
}.freeze
# :md is shadcn's default size; :xl is a RubyUI extra.
SIZES = {
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md " \
"has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md " \
"has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
md: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md " \
"has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xl: "h-12 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3"
}.freeze
# Square icon buttons. h-/w- instead of size-* so callers can still
# override one side with h-8 or w-8.
ICON_SIZES = {
xs: "h-6 w-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md " \
"[&_svg:not([class*='size-'])]:size-3",
sm: "h-8 w-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
md: "h-9 w-9",
lg: "h-10 w-10",
xl: "h-12 w-12"
}.freeze
VARIANT_ALIASES = {default: :primary}.freeze
# shadcn's size names: default, icon, icon-xs, icon-sm, icon-lg.
SIZE_ALIASES = {default: :md, icon: :md, icon_xs: :xs, icon_sm: :sm, icon_md: :md, icon_lg: :lg, icon_xl: :xl}.freeze
def initialize(type: :button, variant: :primary, size: :md, icon: false, href: nil,
disabled: false, focusable_when_disabled: false, **attrs)
@type = type
@variant = variant.to_s.to_sym
@variant = VARIANT_ALIASES.fetch(@variant, @variant)
@variant = :primary unless VARIANTS.key?(@variant)
size = size.to_s.tr("-", "_").to_sym
@icon = icon || size.start_with?("icon")
@size = SIZE_ALIASES.fetch(size, size)
@size = :md unless SIZES.key?(@size)
@href = href
@disabled = disabled
@focusable_when_disabled = focusable_when_disabled
super(**attrs)
# mix concatenates strings, so a caller's own aria: {disabled: "true"}
# would turn ours into "true true", which the controller doesn't read as
# disabled.
@attrs[:aria][:disabled] = "true" if aria_disabled?
end
def view_template(&)
if @href
a(**attrs, &)
else
button(**attrs, &)
end
end
private
def default_attrs
data = {slot: "button"}
attrs = {class: [BASE, (@icon ? ICON_SIZES : SIZES).fetch(@size), VARIANTS.fetch(@variant)], data: data}
if @href
# Like Base UI's render={<a />}: the link is a button to assistive
# tech, and the controller makes Space activate it. A disabled link
# has no href, so it can't be followed.
attrs[:role] = "button"
attrs[:href] = @href unless @disabled
attrs[:tabindex] = 0 if focusable?
data[:controller] = "ruby-ui--button"
else
attrs[:type] = @type
end
if @disabled
data[:disabled] = true
if aria_disabled?
attrs[:aria] = {disabled: "true"}
data[:controller] = "ruby-ui--button"
else
attrs[:disabled] = true
end
end
attrs
end
def focusable?
!@disabled || @focusable_when_disabled
end
# Disabled through aria-disabled instead of the disabled attribute: links
# have no disabled attribute, and focusable_when_disabled must keep focus.
def aria_disabled?
@disabled && (@href || @focusable_when_disabled)
end
end
end
2
Add RubyUI::ButtonDocs to app/components/ruby_ui/button/button_docs.rb
# frozen_string_literal: true
class Views::Docs::Button < Views::Base
def view_template
component = "Button"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Button", description: "Displays a button or a component that looks like a button.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Default", context: self) do
<<~RUBY
Button { "Button" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Outline", context: self) do
<<~RUBY
Button(variant: :outline) { "Outline" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Secondary", context: self) do
<<~RUBY
Button(variant: :secondary) { "Secondary" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Ghost", context: self) do
<<~RUBY
Button(variant: :ghost) { "Ghost" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Destructive", context: self) do
<<~RUBY
Button(variant: :destructive) { "Destructive" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Link", context: self) do
<<~RUBY
Button(variant: :link) { "Link" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Sizes", description: "size: :xs, :sm, :md (the default), :lg and :xl.", context: self) do
<<~RUBY
div(class: "flex flex-wrap items-center gap-2 justify-center") do
Button(variant: :outline, size: :xs) { "Extra Small" }
Button(variant: :outline, size: :sm) { "Small" }
Button(variant: :outline) { "Default" }
Button(variant: :outline, size: :lg) { "Large" }
Button(variant: :outline, size: :xl) { "Extra Large" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Icon", description: "icon: true makes a square button of the given size. Give it an aria-label.", context: self) do
<<~RUBY
div(class: "flex flex-wrap items-center gap-2 justify-center") do
[:xs, :sm, :md, :lg, :xl].each do |size|
Button(variant: :outline, size: size, icon: true, aria: {label: "Next"}) do
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true") do |s|
s.path(d: "m9 18 6-6-6-6")
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "With Icon", description: "Mark the icon with data-icon=\"inline-start\" or \"inline-end\" so the button tightens the padding on that side. Icons are sized to 16px (12px on :xs) unless they carry a size-* class.", context: self) do
<<~RUBY
div(class: "flex flex-wrap items-center gap-2 justify-center") do
Button(variant: :outline) do
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true", data: {icon: "inline-start"}) do |s|
s.path(d: "M6 3v12")
s.circle(cx: "18", cy: "6", r: "3")
s.circle(cx: "6", cy: "18", r: "3")
s.path(d: "M18 9a9 9 0 0 1-9 9")
end
plain "New Branch"
end
Button do
plain "Send"
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true", data: {icon: "inline-end"}) do |s|
s.path(d: "m22 2-7 20-4-9-9-4Z")
s.path(d: "M22 2 11 13")
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Loading", context: self) do
<<~RUBY
Button(variant: :outline, disabled: true) do
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none",
stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
aria_hidden: "true", class: "animate-spin", data: {icon: "inline-start"}) do |s|
s.path(d: "M21 12a9 9 0 1 1-6.219-8.56")
end
plain "Please wait"
end
RUBY
end
render Docs::VisualCodeExample.new(title: "As Link", description: "Pass href: to render an <a>. As with shadcn's render={<a />}, it carries role=\"button\": screen readers announce a button, and Space activates it like Enter.", context: self) do
<<~RUBY
Button(href: "#") { "Login" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Disabled", context: self) do
<<~RUBY
Button(disabled: true) { "Disabled" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Focusable When Disabled", description: "focusable_when_disabled: true keeps a disabled button in the tab order (aria-disabled instead of disabled), for example so a tooltip can explain why. Clicks and keyboard activation are blocked. As in shadcn, only the disabled attribute dims a button, so this one keeps its enabled look.", context: self) do
<<~RUBY
Button(disabled: true, focusable_when_disabled: true) { "Focusable" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Invalid", description: "aria-invalid paints the border and focus ring in the destructive color.", context: self) do
<<~RUBY
Button(variant: :outline, aria: {invalid: "true"}) { "Pick a date" }
RUBY
end
render Docs::VisualCodeExample.new(title: "Submit", context: self) do
<<~RUBY
Button(type: :submit) { "Submit application" }
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
3
Add button_controller.js to app/javascript/controllers/ruby_ui/button_controller.js
import { Controller } from "@hotwired/stimulus";
// Wired on Button(href:) and on Button(disabled: true, focusable_when_disabled: true).
//
// - An <a role="button"> has to activate on Space like a native button
// (Enter already follows the link). Space is handled on keyup, as a native
// button does, and its keydown is cancelled so the page doesn't scroll.
// - While aria-disabled="true", every activation is blocked. Keyboard
// activation arrives as a click too. The listener uses the capture phase:
// it runs before every bubbling listener on the element and its ancestors,
// so no Stimulus action (with or without :prevent/:stop, whatever order the
// controllers connected in) and no form submission sees the click.
export default class extends Controller {
connect() {
this.element.addEventListener("click", this.preventActivation, { capture: true });
if (!this.isNativeButton) {
this.element.addEventListener("keydown", this.cancelSpaceScroll);
this.element.addEventListener("keyup", this.clickOnSpace);
}
}
disconnect() {
this.element.removeEventListener("click", this.preventActivation, { capture: true });
this.element.removeEventListener("keydown", this.cancelSpaceScroll);
this.element.removeEventListener("keyup", this.clickOnSpace);
}
preventActivation = (event) => {
if (!this.disabled) return;
event.preventDefault();
event.stopImmediatePropagation();
};
cancelSpaceScroll = (event) => {
if (event.key === " ") event.preventDefault();
};
clickOnSpace = (event) => {
if (event.key !== " ") return;
event.preventDefault();
this.element.click();
};
get disabled() {
return this.element.getAttribute("aria-disabled") === "true";
}
get isNativeButton() {
return this.element.tagName === "BUTTON";
}
}
4
Update the Stimulus controllers manifest file
rake stimulus:manifest:updateComponents
| Component | Built using | Source |
|---|---|---|
Button | Phlex | |
ButtonDocs | Phlex | |
ButtonController | Stimulus JS |