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>
<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.