Button
Interactive element that triggers an action or navigates to a URL.
Quick Start
<%= kui(:button) { "Click me" } %>
Locals
| Local | Type | Default |
|---|---|---|
color: |
:primary | :secondary | :success | :info | :warning | :error | :neutral |
:primary |
variant: |
:solid | :outline | :soft | :subtle | :ghost | :link |
:solid |
size: |
:xs | :sm | :md | :lg | :xl |
:md |
block: |
Boolean |
false |
disabled: |
Boolean |
false |
loading: |
Boolean |
false |
loading_auto: |
Boolean |
false |
type: |
:button | :submit | :reset |
:button |
href: |
String | nil |
nil |
method: |
:delete | :post | :put | :patch | nil |
nil |
turbo: |
Boolean |
false |
form: |
Hash |
{} |
css_classes: |
String |
"" |
**component_options |
Hash |
{} |
Usage
Color
<%= kui(:button, color: :primary) { "Primary" } %>
<%= kui(:button, color: :secondary) { "Secondary" } %>
<%= kui(:button, color: :success) { "Success" } %>
<%= kui(:button, color: :info) { "Info" } %>
<%= kui(:button, color: :warning) { "Warning" } %>
<%= kui(:button, color: :error) { "Error" } %>
<%= kui(:button, color: :neutral) { "Neutral" } %>
Variant
Six variants. The core four (solid, outline, soft, subtle) use the standard compound variant formulas. Ghost and link are Button-only additions.
<%= kui(:button, variant: :solid) { "Solid" } %>
<%= kui(:button, variant: :outline) { "Outline" } %>
<%= kui(:button, variant: :soft) { "Soft" } %>
<%= kui(:button, variant: :subtle) { "Subtle" } %>
<%= kui(:button, variant: :ghost) { "Ghost" } %>
<%= kui(:button, variant: :link) { "Link" } %>
Size
<%= kui(:button, size: :xs) { "Extra Small" } %>
<%= kui(:button, size: :sm) { "Small" } %>
<%= kui(:button, size: :md) { "Medium" } %>
<%= kui(:button, size: :lg) { "Large" } %>
<%= kui(:button, size: :xl) { "Extra Large" } %>
Smart Tag
When href: is present, renders <a> instead of <button>. When method:
is also present (e.g. :delete, :post), wraps the styled button in a
<form> with hidden method override and CSRF token for non-GET HTTP methods.
<%# Renders <button> %>
<%= kui(:button) { "Action" } %>
<%# Renders <a href="/settings"> %>
<%= kui(:button, href: "/settings") { "Settings" } %>
<%# Renders <form> + <button> via button_to %>
<%= kui(:button, href: session_path, method: :delete) { "Sign out" } %>
Disabled
For <button>, sets the native disabled attribute. For <a>, sets
aria-disabled="true".
<%= kui(:button, disabled: true) { "Unavailable" } %>
<%= kui(:button, href: "#", disabled: true) { "Disabled Link" } %>
Loading
Shows an animated spinner before the content, disables the button, and sets
aria-busy="true". The spinner inherits its size from the button.
<%= kui(:button, loading: true) { "Saving..." } %>
<%= kui(:button, loading: true, variant: :outline) { "Loading" } %>
Works across all tag variants (button, link, button_to):
<%= kui(:button, type: :submit, loading: true) { "Submitting..." } %>
<%= kui(:button, href: "#", loading: true) { "Loading Link" } %>
Loading Auto
Automatically enters loading state on Turbo form submission and restores when the response arrives. No custom Stimulus controllers needed.
<%= kui(:button, type: :submit, loading_auto: true) { "Save" } %>
Uses the kiso--button-loading Stimulus controller under the hood. Listens
for turbo:submit-start and turbo:submit-end on the closest form.
Block
Full-width button.
<%= kui(:button, block: true) { "Full Width" } %>
Submit
Defaults to type: :button for safety. Set type: :submit explicitly
for forms.
<%= kui(:button, type: :submit, color: :primary) { "Save" } %>
With Icon
Drop an SVG inside the yield block. The button’s gap handles spacing.
SVGs without an explicit size-* class are auto-sized to match the button
size via [&_svg:not([class*='size-'])]:size-4.
<%= kui(:button, variant: :outline) do %>
<%= kiso_icon("plus") %>
Add Item
<% end %>
Form Method
For destructive or state-changing actions that need non-GET HTTP methods
(sign out, delete, archive), pass method: along with href:. This wraps
the button in a <form> with a hidden _method field and CSRF token. The
form uses display: contents so it’s invisible to flex/grid layout.
<%= kui(:button, href: session_path, method: :delete, variant: :ghost) do %>
<%= kiso_icon("log-out") %> Sign out
<% end %>
<%= kui(:button, href: post_path(@post), method: :delete, color: :error) do %>
<%= kiso_icon("trash-2") %> Delete
<% end %>
Works with Turbo confirm — data-turbo-confirm goes on the <button>:
<%= kui(:button, href: post_path(@post), method: :delete,
color: :error, data: { turbo_confirm: "Are you sure?" }) { "Delete" } %>
For form-level attributes (e.g. Turbo Frame targeting), use form::
<%= kui(:button, href: archive_path, method: :post,
form: { data: { turbo_frame: "_top" } }) { "Archive" } %>
Note: Do not place a method: button inside an existing <form> — this
creates invalid nested forms. Use turbo: true instead (see below), or use
button_to directly with Kiso::Themes::Button.render(...) for styling.
Turbo Method
When rendering inside ActionCable broadcasts or Turbo Streams (no request
context), the form approach can’t generate a valid CSRF token. Pass
turbo: true to render an <a data-turbo-method="..."> instead — Turbo
reads the CSRF token from the page’s <meta> tag at click time.
<%= kui(:button, href: session_path, method: :delete, turbo: true, variant: :ghost) do %>
<%= kiso_icon("log-out") %> Sign out
<% end %>
This is safe inside existing <form> elements since it renders a link, not
a nested form.
Examples
Form Actions
<div class="flex gap-3">
<%= kui(:button, type: :submit) { "Save" } %>
<%= kui(:button, variant: :ghost, data: { action: "click->form#reset" }) { "Cancel" } %>
</div>
Link Group
<div class="flex gap-2">
<%= kui(:button, href: "/dashboard", variant: :solid) { "Dashboard" } %>
<%= kui(:button, href: "/settings", variant: :outline) { "Settings" } %>
<%= kui(:button, href: "/help", variant: :ghost) { "Help" } %>
</div>
Theme
# lib/kiso/themes/button.rb
Kiso::Themes::Button = ClassVariants.build(
base: "inline-flex items-center justify-center gap-2 font-medium
whitespace-nowrap shrink-0 transition-all
focus-visible:outline-2 focus-visible:outline-offset-2
disabled:pointer-events-none disabled:opacity-50
aria-disabled:cursor-not-allowed aria-disabled:opacity-50
[&_svg:not([class*='size-'])]:size-4
[&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
solid: "", outline: "ring ring-inset", soft: "", subtle: "ring ring-inset",
ghost: "", link: "underline-offset-4"
},
size: {
xs: "h-7 px-2 py-1 text-xs rounded-md gap-1 has-[>svg]:px-1.5",
sm: "h-8 px-3 py-1.5 text-xs rounded-md gap-1.5 has-[>svg]:px-2.5",
md: "h-9 px-4 py-2 text-sm rounded-md gap-2 has-[>svg]:px-3",
lg: "h-10 px-5 py-2.5 text-sm rounded-md gap-2 has-[>svg]:px-4",
xl: "h-11 px-6 py-3 text-base rounded-lg gap-2.5 has-[>svg]:px-5"
},
color: COLORS.index_with { "" },
block: { true => "w-full", false => "" }
},
compound_variants: [
# Core 4 variants: same formulas as Badge/Alert + hover/active/focus states.
# Ghost + link: Button-only additions.
# See project/design-system.md for base formulas.
],
defaults: { color: :primary, variant: :solid, size: :md, block: false }
)
Interactive States
Button extends the base compound variant formulas with hover, active, and focus-visible states:
| Variant | Hover | Active | Focus |
|---|---|---|---|
| solid | bg-{color}/90 |
bg-{color}/80 |
outline-{color} |
| outline | bg-{color}/10 |
bg-{color}/15 |
ring-2 ring-{color} |
| soft | bg-{color}/15 |
bg-{color}/20 |
outline-{color} |
| subtle | bg-{color}/15 |
bg-{color}/20 |
ring-2 ring-{color} |
| ghost | bg-{color}/10 |
bg-{color}/15 |
outline-{color} |
| link | text-{color}/75 |
text-{color}/75 |
outline-{color} |
Accessibility
| Attribute | Value |
|---|---|
data-slot |
"button" |
type |
"button" (default, not "submit") |
disabled |
Native attribute on <button> |
aria-disabled |
Set on <a> when disabled: true |
aria-busy |
Set when loading: true |
Keyboard
| Key | Action |
|---|---|
Enter |
Activates the button. |
Space |
Activates the button. |
Tab |
Moves focus to the next focusable element. |