shshadcn-htmx

Components

Button

A native <button> element with shadcn variants, rendered server-side and ready to wire to htmx v4. The long Tailwind classes never appear in your source — your template engine renders them for you.

Installation

One file per stack — no npm package, no build step required. Use the shadcn CLI for JSX projects, or copy the source straight into your template directory.

1. Install via the shadcn CLI

Installs components/ui/button.tsx and lib/cn.ts into your project.

npx shadcn@latest add http://localhost/r/button.json

2. Use it

app/some-page.tsx
import { Button } from "@/components/ui/button"

<Button hx-post="/save">Save</Button>
Or copy the source manually
components/ui/button.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cloneElement, isValidElement } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Variants mirror shadcn/ui's Button (new-york-v4), translated to htmx-friendly
// server-rendered JSX. Source of truth:
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/button.tsx
//
// Accessibility contract follows the WAI-ARIA APG button pattern:
//   repos/aria-practices/content/patterns/button/button-pattern.html
// Because we render a real <button>, role and Space/Enter activation come for
// free from the platform — we only add aria-* where the pattern demands it.
//
// Polymorphic rendering: shadcn uses Radix Slot.Root for `asChild`. Hono JSX
// has cloneElement, so we implement the same idea — pass a single JSX child
// (e.g. <a href="...">), and the button classes are merged onto it.

export type ButtonVariant =
  | "default"
  | "destructive"
  | "outline"
  | "secondary"
  | "ghost"
  | "link"

export type ButtonSize =
  | "default"
  | "xs"
  | "sm"
  | "lg"
  | "icon"
  | "icon-xs"
  | "icon-sm"
  | "icon-lg"

const base =
  "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "disabled:pointer-events-none disabled:opacity-50 " +
  // aria-disabled mirrors the disabled affordance for cases where the element
  // must stay focusable (so screen readers can land on it and announce why
  // it's unavailable). See repos/mdn/files/en-us/web/accessibility/aria/attributes/aria-disabled/.
  "aria-disabled:pointer-events-none aria-disabled:opacity-50 " +
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " +
  // htmx v4: while a request triggered by/targeting this button is in flight,
  // htmx adds the .htmx-request class. We mirror disabled affordance.
  "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"

const variants: Record<ButtonVariant, string> = {
  default: "bg-primary text-primary-foreground hover:bg-primary/90",
  destructive:
    "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
  outline:
    "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
  link: "text-primary underline-offset-4 hover:underline",
}

const sizes: Record<ButtonSize, string> = {
  default: "h-9 px-4 py-2 has-[>svg]:px-3",
  xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
  sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
  lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
  icon: "size-9",
  "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
  "icon-sm": "size-8",
  "icon-lg": "size-10",
}

export function buttonClasses(opts?: {
  variant?: ButtonVariant
  size?: ButtonSize
  class?: ClassValue
}): string {
  const variant = opts?.variant ?? "default"
  const size = opts?.size ?? "default"
  return cn(base, variants[variant], sizes[size], opts?.class)
}

// Props beyond visual variants. We intentionally type the standard <button>
// attributes we actually want IDE support for. Hono's JSX accepts unknown
// attribute names on intrinsic elements, but typing the common ones keeps
// call sites honest.
type ButtonProps = PropsWithChildren<{
  variant?: ButtonVariant
  size?: ButtonSize
  class?: ClassValue
  type?: "button" | "submit" | "reset"
  disabled?: boolean
  // aria-disabled keeps the element focusable while its action is unavailable
  // (so a screen reader can land on it and announce it), unlike native
  // `disabled` which removes it from the a11y tree / tab order. Independent of
  // `disabled`. See repos/aria-practices/content/patterns/button/button-pattern.html
  // and repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-disabled/.
  ariaDisabled?: boolean
  // APG: ARIA toggle button. When set, aria-pressed reflects the state and
  // the label must stay constant across states. aria-pressed is tri-state:
  // "mixed" means the items the toggle controls don't all share one value.
  // See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-pressed/.
  pressed?: boolean | "mixed"
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string

  // Disclosure / menu / popover trigger contract. Lets a styled Button act as
  // an expandable trigger (accordion/collapsible) or menu/listbox/dialog
  // opener without hand-rolling a bare <button>.
  // See repos/aria-practices/content/patterns/button/button-pattern.html
  // and repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-expanded/
  // and repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-haspopup/.
  ariaExpanded?: boolean
  ariaHaspopup?: boolean | "menu" | "listbox" | "tree" | "grid" | "dialog"
  ariaControls?: string

  // Standard form attributes (MDN <button>). Useful for multi-submit-button
  // forms where one button posts to a different URL or method.
  id?: string
  name?: string
  value?: string
  form?: string
  formaction?: string
  formenctype?: "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain"
  formmethod?: "get" | "post" | "dialog"
  formnovalidate?: boolean
  formtarget?: string
  popovertarget?: string
  popovertargetaction?: "show" | "hide" | "toggle"

  // Focus this button on initial page load (one per document).
  autofocus?: boolean

  // Invoker API (newer than popovertarget — declarative dialog/popover
  // control). `command` is one of: show-modal | close | request-close |
  // show-popover | hide-popover | toggle-popover | --custom; `commandfor`
  // is the target element id.
  // See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-85
  command?:
    | "show-modal"
    | "close"
    | "request-close"
    | "show-popover"
    | "hide-popover"
    | "toggle-popover"
    | (string & {}) // `--custom-command` is allowed too
  commandfor?: string

  // htmx v4 attributes (subset). See repos/htmx/www/src/content/reference/01-attributes/.
  "hx-get"?: string
  "hx-post"?: string
  "hx-put"?: string
  "hx-patch"?: string
  "hx-delete"?: string
  "hx-target"?: string
  "hx-swap"?: string
  "hx-trigger"?: string
  "hx-indicator"?: string
  "hx-confirm"?: string
  "hx-vals"?: string
  // v4: "disable form elements during requests" (renamed from v3's hx-disabled-elt).
  // See repos/htmx/www/src/content/docs/01-get-started/02-migration.md.
  "hx-disable"?: string

  // Render as the single JSX child element (anchor, label, etc.) with the
  // button classes merged onto it. SSR-friendly equivalent of shadcn's
  // Radix-Slot-based `asChild` pattern.
  asChild?: boolean
}>

export function Button(props: ButtonProps) {
  const {
    children,
    variant,
    size,
    class: className,
    type = "button",
    disabled,
    ariaDisabled,
    pressed,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    ariaExpanded,
    ariaHaspopup,
    ariaControls,
    asChild,
    ...rest
  } = props

  const classes = buttonClasses({ variant, size, class: className })

  // asChild path: clone the single child and merge classes/data-* onto it so
  // the call site can render as <a>, <label>, etc. while keeping the visual
  // contract. Throws softly (returns the children unchanged) if the child
  // isn't a valid element.
  if (asChild && isValidElement(children)) {
    const child = children as any
    const merged = cn(classes, child?.props?.class)
    return cloneElement(child, {
      ...rest,
      class: merged,
      "data-slot": "button",
      "data-variant": variant ?? "default",
      "data-size": size ?? "default",
      "aria-disabled": ariaDisabled ? "true" : undefined,
      "aria-pressed": pressed === undefined ? undefined : pressed,
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledby,
      "aria-describedby": ariaDescribedby,
      "aria-expanded": ariaExpanded === undefined ? undefined : ariaExpanded,
      "aria-haspopup": ariaHaspopup === undefined ? undefined : ariaHaspopup,
      "aria-controls": ariaControls,
    })
  }

  return (
    <button
      type={type}
      class={classes}
      disabled={disabled}
      aria-disabled={ariaDisabled ? "true" : undefined}
      aria-pressed={pressed === undefined ? undefined : pressed}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      aria-expanded={ariaExpanded === undefined ? undefined : ariaExpanded}
      aria-haspopup={ariaHaspopup === undefined ? undefined : ariaHaspopup}
      aria-controls={ariaControls}
      data-slot="button"
      data-variant={variant ?? "default"}
      data-size={size ?? "default"}
      {...rest}
    >
      {children}
    </button>
  )
}
lib/cn.ts
// Minimal class-name joiner. Drops falsy values; flattens arrays.
// Equivalent to clsx for the subset we need. No tailwind-merge — author
// the variants so they don't collide, the way shadcn does for cva-defined classes.
export type ClassValue = string | number | null | undefined | false | ClassValue[]

export function cn(...inputs: ClassValue[]): string {
  const out: string[] = []
  const walk = (v: ClassValue) => {
    if (!v && v !== 0) return
    if (Array.isArray(v)) {
      for (const x of v) walk(x)
    } else {
      out.push(String(v))
    }
  }
  for (const v of inputs) walk(v)
  return out.join(" ")
}

1. Save the macro

Copy button.html below into your Jinja templates directory (commonly templates/components/).

2. Use it

templates/page.html
{% from "components/button.html" import button %}

{{ button("Save", hx_post="/save") }}
Source — button.html
templates/components/button.html
{# Button macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/button.tsx so a user on a Python/Flask/FastAPI/Django
   project can render the same markup our docs site renders.

   Usage:
       {% from "components/button.html" import button %}
       {{ button("Save", hx_post="/save") }}
       {{ button("Delete", variant="destructive", size="sm") }}

   Multi-submit forms (button posts to a per-button URL):
       {{ button("Save and continue",
                  type="submit", name="action", value="continue",
                  formaction="/orders/save", formmethod="post") }}

   All hx-* attributes are passed through via **attrs (underscores become
   dashes, so `hx_post="/save"` emits `hx-post="/save"`).

   The macro emits a native <button>, so role and Space/Enter activation come
   for free. See repos/aria-practices/content/patterns/button/. #}

{% macro button(
    label,
    variant="default",
    size="default",
    type="button",
    disabled=false,
    aria_disabled=false,
    pressed=none,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    aria_expanded=none,
    aria_haspopup=none,
    aria_controls=none,
    id=none,
    name=none,
    value=none,
    form=none,
    formaction=none,
    formenctype=none,
    formmethod=none,
    formnovalidate=false,
    formtarget=none,
    popovertarget=none,
    popovertargetaction=none,
    extra_class="",
    **attrs
) %}
{%- set base -%}
inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70
{%- endset -%}

{%- set variants = {
    "default": "bg-primary text-primary-foreground hover:bg-primary/90",
    "destructive": "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
    "outline": "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
    "secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80",
    "ghost": "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
    "link": "text-primary underline-offset-4 hover:underline"
} -%}

{%- set sizes = {
    "default": "h-9 px-4 py-2 has-[>svg]:px-3",
    "xs": "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
    "sm": "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
    "lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
    "icon": "size-9",
    "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
    "icon-sm": "size-8",
    "icon-lg": "size-10"
} -%}

<button type="{{ type }}"
        class="{{ base }} {{ variants[variant] }} {{ sizes[size] }} {{ extra_class }}"
        {%- if id %} id="{{ id }}"{% endif %}
        {%- if name %} name="{{ name }}"{% endif %}
        {%- if value is not none %} value="{{ value }}"{% endif %}
        {%- if form %} form="{{ form }}"{% endif %}
        {%- if formaction %} formaction="{{ formaction }}"{% endif %}
        {%- if formenctype %} formenctype="{{ formenctype }}"{% endif %}
        {%- if formmethod %} formmethod="{{ formmethod }}"{% endif %}
        {%- if formnovalidate %} formnovalidate{% endif %}
        {%- if formtarget %} formtarget="{{ formtarget }}"{% endif %}
        {%- if popovertarget %} popovertarget="{{ popovertarget }}"{% endif %}
        {%- if popovertargetaction %} popovertargetaction="{{ popovertargetaction }}"{% endif %}
        {%- if disabled %} disabled{% endif %}
        {# aria-disabled stays focusable while unavailable, unlike `disabled`.
           See repos/aria-practices/content/patterns/button/button-pattern.html #}
        {%- if aria_disabled %} aria-disabled="true"{% endif %}
        {# aria-pressed is tri-state: true | false | "mixed". See
           repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-pressed/ #}
        {%- if pressed is not none %} aria-pressed="{{ pressed if pressed is string else ('true' if pressed else 'false') }}"{% endif %}
        {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
        {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
        {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
        {# Disclosure / menu trigger contract. See
           repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-expanded/
           and .../aria-haspopup/ #}
        {%- if aria_expanded is not none %} aria-expanded="{{ aria_expanded if aria_expanded is string else ('true' if aria_expanded else 'false') }}"{% endif %}
        {%- if aria_haspopup is not none %} aria-haspopup="{{ aria_haspopup if aria_haspopup is string else ('true' if aria_haspopup else 'false') }}"{% endif %}
        {%- if aria_controls %} aria-controls="{{ aria_controls }}"{% endif %}
        data-slot="button" data-variant="{{ variant }}" data-size="{{ size }}"
        {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label }}</button>
{% endmacro %}

1. Save the template

Copy button.tmpl below into your templates/ tree and ParseFiles it like any other Go template.

2. Use it

handler.go
tpl.ExecuteTemplate(w, "button", map[string]any{
    "Label": "Save",
    "Attrs": map[string]string{"hx-post": "/save"},
})
Source — button.tmpl
templates/components/button.tmpl
{{/*
  Button template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/button.tsx for Go projects using html/template.

  Usage in your code:

      type ButtonArgs struct {
          Label    string
          Variant  string // default | destructive | outline | secondary | ghost | link
          Size     string // default | xs | sm | lg | icon | icon-xs | icon-sm | icon-lg
          Type     string // button | submit | reset
          Disabled bool
          // AriaDisabled keeps the button focusable while its action is
          // unavailable (unlike Disabled, which drops it from the a11y tree).
          AriaDisabled bool
          // Pressed is the aria-pressed toggle state. Use a *bool for true/false,
          // or set PressedMixed=true for the tri-state "mixed" value.
          // aria-pressed is tri-state: true | false | "mixed".
          Pressed      *bool
          PressedMixed bool

          // ARIA
          AriaLabel       string
          AriaLabelledby  string
          AriaDescribedby string
          // Disclosure / menu trigger contract.
          AriaExpanded     *bool
          AriaHaspopup     string // true | menu | listbox | tree | grid | dialog
          AriaControls     string

          // Standard <button> form attributes — useful for multi-submit forms.
          ID             string
          Name           string
          Value          string
          Form           string
          FormAction     string
          FormEnctype    string // application/x-www-form-urlencoded | multipart/form-data | text/plain
          FormMethod     string // get | post | dialog
          FormNoValidate bool
          FormTarget     string
          PopoverTarget  string
          PopoverTargetAction string // show | hide | toggle

          // Everything else (hx-post, hx-target, hx-swap, …) goes here.
          Attrs map[string]string
      }

      // Once at startup:
      tpl := template.Must(template.New("").ParseFiles("components/button.tmpl"))

      // Then per-render:
      tpl.ExecuteTemplate(w, "button", ButtonArgs{
          Label: "Save", Variant: "default",
          Attrs: map[string]string{"hx-post": "/save"},
      })

  The template emits a native <button>, so role and Space/Enter activation
  come for free. See repos/aria-practices/content/patterns/button/.
*/}}

{{define "button"}}
{{- $variants := dict
    "default" "bg-primary text-primary-foreground hover:bg-primary/90"
    "destructive" "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40"
    "outline" "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
    "secondary" "bg-secondary text-secondary-foreground hover:bg-secondary/80"
    "ghost" "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
    "link" "text-primary underline-offset-4 hover:underline" -}}
{{- $sizes := dict
    "default" "h-9 px-4 py-2 has-[>svg]:px-3"
    "xs" "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3"
    "sm" "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5"
    "lg" "h-10 rounded-md px-6 has-[>svg]:px-4"
    "icon" "size-9"
    "icon-xs" "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3"
    "icon-sm" "size-8"
    "icon-lg" "size-10" -}}
{{- $variant := or .Variant "default" -}}
{{- $size := or .Size "default" -}}
{{- $type := or .Type "button" -}}
{{- $base := "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70" -}}
<button type="{{$type}}"
        class="{{$base}} {{index $variants $variant}} {{index $sizes $size}}"
        {{- if .ID}} id="{{.ID}}"{{end}}
        {{- if .Name}} name="{{.Name}}"{{end}}
        {{- if .Value}} value="{{.Value}}"{{end}}
        {{- if .Form}} form="{{.Form}}"{{end}}
        {{- if .FormAction}} formaction="{{.FormAction}}"{{end}}
        {{- if .FormEnctype}} formenctype="{{.FormEnctype}}"{{end}}
        {{- if .FormMethod}} formmethod="{{.FormMethod}}"{{end}}
        {{- if .FormNoValidate}} formnovalidate{{end}}
        {{- if .FormTarget}} formtarget="{{.FormTarget}}"{{end}}
        {{- if .PopoverTarget}} popovertarget="{{.PopoverTarget}}"{{end}}
        {{- if .PopoverTargetAction}} popovertargetaction="{{.PopoverTargetAction}}"{{end}}
        {{- if .Disabled}} disabled{{end}}
        {{/* aria-disabled stays focusable while unavailable, unlike disabled.
             See repos/aria-practices/content/patterns/button/button-pattern.html */}}
        {{- if .AriaDisabled}} aria-disabled="true"{{end}}
        {{/* aria-pressed is tri-state: true | false | "mixed". See
             repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-pressed/ */}}
        {{- if .PressedMixed}} aria-pressed="mixed"
        {{- else if .Pressed}} aria-pressed="{{if deref .Pressed}}true{{else}}false{{end}}"{{end}}
        {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
        {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
        {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
        {{/* Disclosure / menu trigger contract. See
             repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-expanded/
             and .../aria-haspopup/ */}}
        {{- if .AriaExpanded}} aria-expanded="{{if deref .AriaExpanded}}true{{else}}false{{end}}"{{end}}
        {{- if .AriaHaspopup}} aria-haspopup="{{.AriaHaspopup}}"{{end}}
        {{- if .AriaControls}} aria-controls="{{.AriaControls}}"{{end}}
        data-slot="button" data-variant="{{$variant}}" data-size="{{$size}}"
        {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Label}}</button>
{{end}}

{{/*
  Note: this template uses sprig's `dict` and `deref` helpers. If you don't
  use sprig, hard-code the lookup or pass the class string from Go code:

      args.Class = computeButtonClass(args.Variant, args.Size)

  and reference {{.Class}} directly in the template.
*/}}

1. Save the component module

Copy button.ex below into lib/my_app_web/components/ — it defines a Phoenix function component.

2. Use it

lib/my_app_web/live/some_live.html.heex
alias ShadcnHtmxWeb.Components.Button

<Button.button hx-post="/save">Save</Button.button>
Source — button.ex
lib/my_app_web/components/button.ex
defmodule ShadcnHtmx.Components.Button do
  @moduledoc """
  Button — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/button.tsx so a Phoenix LiveView project can render
  the same markup our docs site renders. Works with plain HEEx templates
  too — htmx attributes pass straight through via `:rest`.

  ## Examples

      <.button hx-post="/save">Save</.button>
      <.button variant="destructive" size="sm">Delete</.button>
      <.button pressed={true} aria-label="Mute">Mute</.button>

      # Multi-submit form (one button posts to a different URL)
      <.button type="submit" name="action" value="continue"
               formaction="/orders/save" formmethod="post">
        Save and continue
      </.button>

  The button is a native `<button>` so role and Space/Enter activation come
  for free. See repos/aria-practices/content/patterns/button/.
  """

  use Phoenix.Component

  @variants %{
    "default" => "bg-primary text-primary-foreground hover:bg-primary/90",
    "destructive" =>
      "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
    "outline" =>
      "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
    "secondary" => "bg-secondary text-secondary-foreground hover:bg-secondary/80",
    "ghost" => "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
    "link" => "text-primary underline-offset-4 hover:underline"
  }

  @sizes %{
    "default" => "h-9 px-4 py-2 has-[>svg]:px-3",
    "xs" =>
      "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
    "sm" => "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
    "lg" => "h-10 rounded-md px-6 has-[>svg]:px-4",
    "icon" => "size-9",
    "icon-xs" => "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
    "icon-sm" => "size-8",
    "icon-lg" => "size-10"
  }

  @base "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium " <>
          "whitespace-nowrap transition-all outline-none " <>
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
          "disabled:pointer-events-none disabled:opacity-50 " <>
          "aria-disabled:pointer-events-none aria-disabled:opacity-50 " <>
          "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
          "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " <>
          "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"

  attr :variant, :string,
    default: "default",
    values: ~w(default destructive outline secondary ghost link)

  attr :size, :string,
    default: "default",
    values: ~w(default xs sm lg icon icon-xs icon-sm icon-lg)

  attr :type, :string, default: "button"
  attr :disabled, :boolean, default: false
  # aria-disabled keeps the button focusable while unavailable, unlike `disabled`
  # which drops it from the a11y tree / tab order. Independent of `disabled`.
  # See repos/aria-practices/content/patterns/button/button-pattern.html
  attr :aria_disabled, :boolean, default: false
  # aria-pressed is tri-state: true | false | "mixed". `:any` accepts a "mixed"
  # string for toggles whose controlled items don't all share one value.
  # See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-pressed/
  attr :pressed, :any, default: nil
  # Disclosure / menu trigger contract. See
  # repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-expanded/
  # and .../aria-haspopup/
  attr :aria_expanded, :any, default: nil
  attr :aria_haspopup, :any, default: nil
  attr :class, :string, default: nil

  attr :rest, :global,
    include:
      ~w(hx-get hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger hx-indicator hx-confirm hx-vals hx-disable
         id name value form formaction formenctype formmethod formnovalidate formtarget popovertarget popovertargetaction
         command commandfor
         aria-label aria-labelledby aria-describedby aria-controls)

  slot :inner_block, required: true

  def button(assigns) do
    assigns =
      assigns
      |> assign(:variant_class, Map.fetch!(@variants, assigns.variant))
      |> assign(:size_class, Map.fetch!(@sizes, assigns.size))
      |> assign(:base_class, @base)

    ~H"""
    <button
      type={@type}
      class={[@base_class, @variant_class, @size_class, @class]}
      disabled={@disabled}
      aria-disabled={if @aria_disabled, do: "true", else: nil}
      aria-pressed={if is_nil(@pressed), do: nil, else: to_string(@pressed)}
      aria-expanded={if is_nil(@aria_expanded), do: nil, else: to_string(@aria_expanded)}
      aria-haspopup={if is_nil(@aria_haspopup), do: nil, else: to_string(@aria_haspopup)}
      data-slot="button"
      data-variant={@variant}
      data-size={@size}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end
end

1. Load Tailwind + htmx

Drop these into your <head> if you don't already have a build pipeline. Tailwind's Play CDN compiles utility classes at runtime — fine for prototypes, swap to a real build for production.

index.html
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://unpkg.com/[email protected]/dist/htmx.min.js" defer></script>

2. Paste the button markup

index.html
<!-- Paste straight into your page. No template engine needed. -->
<button type="button"
        class="inline-flex items-center justify-center rounded-md bg-primary
               text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 …"
        hx-post="/save" hx-target="#result">
  Save
</button>
<span id="result" aria-live="polite"></span>
Snippets — variants, sizes, htmx wiring
snippets/button.html
<!--
  shadcn-htmx — raw HTML button snippets.

  No template engine, no JavaScript framework. Just the class strings you need
  on a real <button> element, ready to drop into any HTML file that loads
  Tailwind CSS v4 and (optionally) htmx v4.

  Requirements:
    1. Tailwind CSS v4 set up in your project, OR include the Play CDN for
       quick experiments:
         <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    2. htmx v4 if you want the hx-* attributes to do anything:
         <script src="https://unpkg.com/[email protected]/dist/htmx.min.js" defer></script>
    3. The CSS variables shadcn relies on (--background, --foreground, --primary,
       --primary-foreground, --border, --ring, --destructive, etc.). Copy the
       :root and .dark blocks from app/styles/input.css into your stylesheet —
       these are framework-agnostic.

  The base class string is the same for every snippet — only the variant-
  specific colour utilities and the size-specific dimensions change. We keep
  the full string in each snippet so you can copy a single block and paste it
  into a working button immediately.

  BASE (shared by every variant):
    inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm
    font-medium whitespace-nowrap transition-all outline-none
    focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
    disabled:pointer-events-none disabled:opacity-50
    aria-disabled:pointer-events-none aria-disabled:opacity-50
    aria-invalid:border-destructive aria-invalid:ring-destructive/20
    dark:aria-invalid:ring-destructive/40
    [&_svg]:pointer-events-none [&_svg]:shrink-0
    [&_svg:not([class*='size-'])]:size-4
    [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70
-->

<!-- ─── Variants ────────────────────────────────────────────────────── -->

<!-- Default -->
<button type="button" data-slot="button" data-variant="default" data-size="default"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2">
  Save
</button>

<!-- Secondary -->
<button type="button" data-slot="button" data-variant="secondary" data-size="default"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-4 py-2">
  Cancel
</button>

<!-- Destructive -->
<button type="button" data-slot="button" data-variant="destructive" data-size="default"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 h-9 px-4 py-2">
  Delete
</button>

<!-- Outline -->
<button type="button" data-slot="button" data-variant="outline" data-size="default"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2">
  Outline
</button>

<!-- Ghost -->
<button type="button" data-slot="button" data-variant="ghost" data-size="default"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-9 px-4 py-2">
  Ghost
</button>

<!-- Link -->
<button type="button" data-slot="button" data-variant="link" data-size="default"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 text-primary underline-offset-4 hover:underline h-9 px-4 py-2">
  Learn more
</button>

<!-- ─── Sizes (with the default variant) ────────────────────────────── -->

<!-- xs — 24px tall, dense rows -->
<button type="button" data-slot="button" data-size="xs"
  class="inline-flex shrink-0 items-center justify-center gap-1 rounded-md font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-6 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3">
  Tiny
</button>

<!-- Small -->
<button type="button" data-slot="button" data-size="sm"
  class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3 has-[>svg]:px-2.5">
  Small
</button>

<!-- Large -->
<button type="button" data-slot="button" data-size="lg"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 has-[>svg]:px-4">
  Large
</button>

<!-- ─── Icon-only sizes (always pair with aria-label) ───────────────── -->

<!-- icon-xs — 24×24 -->
<button type="button" data-slot="button" data-size="icon-xs" aria-label="Add"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 bg-primary text-primary-foreground hover:bg-primary/90 size-6 rounded-md">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5 12h14" /><path d="M12 5v14" />
  </svg>
</button>

<!-- icon-sm — 32×32 -->
<button type="button" data-slot="button" data-size="icon-sm" aria-label="Add"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 bg-primary text-primary-foreground hover:bg-primary/90 size-8">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5 12h14" /><path d="M12 5v14" />
  </svg>
</button>

<!-- icon — 36×36 -->
<button type="button" data-slot="button" data-size="icon" aria-label="Add"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 bg-primary text-primary-foreground hover:bg-primary/90 size-9">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5 12h14" /><path d="M12 5v14" />
  </svg>
</button>

<!-- icon-lg — 40×40 -->
<button type="button" data-slot="button" data-size="icon-lg" aria-label="Add"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 bg-primary text-primary-foreground hover:bg-primary/90 size-10">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5 12h14" /><path d="M12 5v14" />
  </svg>
</button>

<!-- ─── States ──────────────────────────────────────────────────────── -->

<!-- Disabled (native attribute — also removes from tab order) -->
<button type="button" disabled
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground h-9 px-4 py-2">
  Disabled
</button>

<!-- Aria-disabled (looks disabled but remains focusable; pair with click handler that no-ops) -->
<button type="button" aria-disabled="true"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:pointer-events-none aria-disabled:opacity-50 bg-primary text-primary-foreground h-9 px-4 py-2">
  Aria-disabled
</button>

<!-- Toggle (aria-pressed) — keep the label constant across states -->
<button type="button" aria-pressed="false" aria-label="Mute"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2">
  Mute
</button>

<!-- Toggle (tri-state) — aria-pressed="mixed" when the controlled items don't
     all share one value (e.g. a Bold toggle over a mixed text selection).
     See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-pressed/ -->
<button type="button" aria-pressed="mixed" aria-label="Bold"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2">
  Bold
</button>

<!-- Menu / disclosure trigger — aria-haspopup announces the popup kind;
     aria-expanded + aria-controls wire the trigger to the controlled element.
     See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-haspopup/
     and .../aria-expanded/ -->
<button type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="actions-menu"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2">
  Actions
</button>

<!-- Aria-invalid (pairs the button to a failing form field via aria-describedby) -->
<button type="submit" aria-invalid="true" aria-describedby="email-error"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2">
  Save
</button>

<!-- ─── Multi-submit form (per-button formaction/formmethod) ────────── -->
<form action="/orders" method="post">
  <!-- normal submit goes to /orders POST -->
  <button type="submit"
    class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2">
    Save
  </button>

  <!-- this submit overrides the form's action + method -->
  <button type="submit" name="action" value="continue"
          formaction="/orders/save" formmethod="post"
    class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2">
    Save and continue
  </button>
</form>

<!-- ─── htmx — fragment swap ────────────────────────────────────────── -->
<!--
  htmx adds .htmx-request to this button while the request is in flight.
  The matching utility (.[&.htmx-request]:opacity-70) provides the visual cue.
  hx-disable="this" (v4; was hx-disabled-elt in v3) blocks repeat submits.
-->
<button type="button"
  hx-post="/save" hx-target="#result" hx-swap="innerHTML" hx-disable="this"
  class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2">
  Save
</button>
<span id="result" aria-live="polite"></span>

Examples

Switch the Code tab between Hono JSX, Jinja2, Go templates, and Phoenix to see how each example reads in your template engine.

Variants

Six built-in variants — same base layout, the colour and emphasis change.

Variants encode visual hierarchy. default marks the primary action on the screen; secondary is the polite alternative; destructive signals irreversible work; outline and ghost step back when paired with a stronger sibling; link reads as inline prose. Rule of thumb — one default per screen.

<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
{{ button("Default") }}
{{ button("Secondary", variant="secondary") }}
{{ button("Destructive", variant="destructive") }}
{{ button("Outline", variant="outline") }}
{{ button("Ghost", variant="ghost") }}
{{ button("Link", variant="link") }}
{{template "button" (dict "Label" "Default")}}
{{template "button" (dict "Label" "Secondary" "Variant" "secondary")}}
{{template "button" (dict "Label" "Destructive" "Variant" "destructive")}}
{{template "button" (dict "Label" "Outline" "Variant" "outline")}}
{{template "button" (dict "Label" "Ghost" "Variant" "ghost")}}
{{template "button" (dict "Label" "Link" "Variant" "link")}}
<Button.button>Default</Button.button>
<Button.button variant="secondary">Secondary</Button.button>
<Button.button variant="destructive">Destructive</Button.button>
<Button.button variant="outline">Outline</Button.button>
<Button.button variant="ghost">Ghost</Button.button>
<Button.button variant="link">Link</Button.button>
<div class="flex flex-wrap items-center justify-center gap-3">
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="default" data-size="default">Default</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="secondary" data-size="default">Secondary</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="destructive" data-size="default">Destructive</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="outline" data-size="default">Outline</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="ghost" data-size="default">Ghost</button>
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 text-primary underline-offset-4 hover:underline h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="link" data-size="default">Link</button>
</div>

Sizes

Four sizes: sm (32px), default (36px), lg (40px), icon (square 36px).

Touch targets matter. WCAG 2.5.5 (AAA) wants 44×44 CSS pixels; iOS HIG also says 44pt; Material says 48dp. The 36px default is fine in pointer-rich UI (tables, dashboards). On touch-first surfaces reach for size="lg". Pair size="icon" with an ariaLabel — the icon alone is invisible to screen readers.

// Text sizes
<Button size="xs">XS</Button>
<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">Large</Button>

// Icon-only — always pair with ariaLabel
<Button size="icon-xs" ariaLabel="Add"><PlusIcon /></Button>
<Button size="icon-sm" ariaLabel="Add"><PlusIcon /></Button>
<Button size="icon"    ariaLabel="Add"><PlusIcon /></Button>
<Button size="icon-lg" ariaLabel="Add"><PlusIcon /></Button>
{{ button("XS",      size="xs") }}
{{ button("Small",   size="sm") }}
{{ button("Default") }}
{{ button("Large",   size="lg") }}
{{template "button" (dict "Label" "XS"      "Size" "xs")}}
{{template "button" (dict "Label" "Small"   "Size" "sm")}}
{{template "button" (dict "Label" "Default")}}
{{template "button" (dict "Label" "Large"   "Size" "lg")}}
<Button.button size="xs">XS</Button.button>
<Button.button size="sm">Small</Button.button>
<Button.button>Default</Button.button>
<Button.button size="lg">Large</Button.button>
<Button.button size="icon" aria-label="Add">
  <.icon name="hero-plus" />
</Button.button>
<div class="flex flex-col items-center gap-5">
  <div class="flex flex-wrap items-end justify-center gap-3">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-6 gap-1 rounded-md px-2 text-xs has-[&gt;svg]:px-1.5 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3" data-slot="button" data-variant="default" data-size="xs">XS</button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button" data-variant="default" data-size="sm">Small</button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="default" data-size="default">Default</button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-10 rounded-md px-6 has-[&gt;svg]:px-4" data-slot="button" data-variant="default" data-size="lg">Large</button>
  </div>
  <div class="flex flex-wrap items-end justify-center gap-3">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 size-6 rounded-md [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3" aria-label="Add" data-slot="button" data-variant="default" data-size="icon-xs">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M5 12h14">
        </path>
        <path d="M12 5v14">
        </path>
      </svg>
    </button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 size-8" aria-label="Add" data-slot="button" data-variant="default" data-size="icon-sm">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M5 12h14">
        </path>
        <path d="M12 5v14">
        </path>
      </svg>
    </button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 size-9" aria-label="Add" data-slot="button" data-variant="default" data-size="icon">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M5 12h14">
        </path>
        <path d="M12 5v14">
        </path>
      </svg>
    </button>
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 size-10" aria-label="Add" data-slot="button" data-variant="default" data-size="icon-lg">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M5 12h14">
        </path>
        <path d="M12 5v14">
        </path>
      </svg>
    </button>
  </div>
</div>

Disabled

Type a name to enable the Save button. The native disabled attribute removes the button from tab order, blocks clicks, and skips form submission.

Reach for the native disabled attribute when the action genuinely cannot proceed (form invalid, server unreachable). When you want the button to look disabled but stay reachable — so a screen reader user can land on it and hear why it's unavailable — use aria-disabled="true" and intercept the click instead.

// pseudo-React; pressed/disabled come from your state
<Button disabled={name.length === 0} type="submit">
  Save
</Button>
{{ button("Save", type="submit", disabled=(name|length == 0)) }}
{{template "button" (dict
  "Label" "Save" "Type" "submit"
  "Disabled" (eq (len .Name) 0)
)}}
<Button.button type="submit" disabled={String.length(@name) == 0}>
  Save
</Button.button>
<form data-disable-when-empty="true" class="flex w-full max-w-sm flex-col items-stretch gap-3" onsubmit="event.preventDefault()">
  <label class="text-xs font-medium" for="ex-disabled-name">Display name</label>
  <input id="ex-disabled-name" name="name" type="text" placeholder="Type to enable Save…" class="h-9 w-full rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"/>
  <button type="submit" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" disabled="" data-slot="button" data-variant="default" data-size="default">Save</button>
</form>

Toggle (aria-pressed)

Click the button. The label stays "Mute"; only aria-pressed flips.

A real toggle is one button with a stable label and an attribute that carries the state. APG requires the label stay constant — "Mute" / "Mute", not "Mute" / "Unmute" — because a screen reader announces the label and the state separately. Pair aria-pressed with a visible affordance (icon fill, border, background) so sighted users see the state too. Don't put aria-pressed on a non-toggle button — it'll confuse assistive tech.

aria-pressed="false"
// Reactive in your framework — pass the latest value of `pressed`.
<Button variant="outline" pressed={isMuted} ariaLabel="Mute">
  Mute
</Button>
{# pressed comes from your view context #}
{{ button("Mute", variant="outline", pressed=is_muted, aria_label="Mute") }}
{{template "button" (dict
  "Label" "Mute" "Variant" "outline"
  "Pressed" (ptr .IsMuted) "AriaLabel" "Mute"
)}}
<Button.button variant="outline" pressed={@is_muted} aria-label="Mute">
  Mute
</Button.button>
<div data-toggle-button="true" class="flex flex-col items-center gap-4 text-center">
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" aria-pressed="false" aria-label="Mute" data-slot="button" data-variant="outline" data-size="default">Mute</button>
  <code data-toggle-state="true" class="rounded-md border bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground">aria-pressed=&quot;false&quot;</code>
</div>

htmx — fragment swap

Click. The browser sends a POST, the server replies with HTML, htmx swaps it into the span.

The server returns HTML, not JSON. hx-post sends the request, hx-target picks the receiver, hx-swap picks the strategy (innerHTML, outerHTML, beforebegin, …). The receiver carries aria-live="polite" so screen readers announce the new text automatically — you don't have to manually call any focus or announce API.

Result will appear here.
<Button hx-post="/clicked" hx-target="#out" hx-swap="innerHTML">
  Click me
</Button>
<span id="out" aria-live="polite">Result will appear here.</span>
{{ button("Click me",
    hx_post="/clicked", hx_target="#out", hx_swap="innerHTML") }}
<span id="out" aria-live="polite">Result will appear here.</span>
{{template "button" (dict
  "Label" "Click me"
  "Attrs" (dict "hx-post" "/clicked" "hx-target" "#out" "hx-swap" "innerHTML")
)}}
<span id="out" aria-live="polite">Result will appear here.</span>
<Button.button hx-post="/clicked" hx-target="#out" hx-swap="innerHTML">
  Click me
</Button.button>
<span id="out" aria-live="polite">Result will appear here.</span>
<div class="flex flex-wrap items-center justify-center gap-3">
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="default" data-size="default" hx-post="/button/clicked" hx-target="#htmx-out" hx-swap="innerHTML">Click me</button>
  <span id="htmx-out" class="text-sm text-muted-foreground" aria-live="polite">Result will appear here.</span>
</div>

htmx — slow endpoint (hx-disable)

The endpoint sleeps for 1.2 s. Click — the button is blocked from repeat submits, the .htmx-request class dims it, then the result lands.

Slow endpoints invite double-submits. hx-disable="this" (v4 — the v3 name was hx-disabled-elt) blocks repeats while the request is in flight. htmx also adds the .htmx-request class to the trigger for the full lifecycle — our base classes pick that up to dim and freeze the button, no extra styling needed in your project.

Idle.
<Button hx-post="/save" hx-target="#out" hx-disable="this">
  Save
</Button>
{{ button("Save",
    hx_post="/save", hx_target="#out", hx_disable="this") }}
{{template "button" (dict
  "Label" "Save"
  "Attrs" (dict "hx-post" "/save" "hx-target" "#out" "hx-disable" "this")
)}}
<Button.button hx-post="/save" hx-target="#out" hx-disable="this">
  Save
</Button.button>
<div class="flex flex-wrap items-center justify-center gap-3">
  <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" data-slot="button" data-variant="default" data-size="default" hx-post="/button/slow" hx-target="#slow-out" hx-swap="innerHTML" hx-disable="this">Save</button>
  <span id="slow-out" class="text-sm text-muted-foreground" aria-live="polite">Idle.</span>
</div>

API Reference

<Button>

All native <button> attributes are forwarded onto the element via ...rest.

PropTypeDefaultDescription
ariaDisabledbooleanfalse
Mark the button unavailable while keeping it focusable (stays in the tab order so a screen reader can land on it). Independent of disabled.MDNaria-disabled
pressedboolean|"mixed"
Toggle-button state forwarded to aria-pressed. Tri-state: true, false, or "mixed" when the controlled items do not all share one value. Keep the label constant across states.MDNaria-pressed
ariaExpandedboolean
Disclosure state forwarded to aria-expanded for a trigger that shows/hides a panel or menu.MDNaria-expanded
ariaHaspopupboolean|"menu"|"listbox"|"tree"|"grid"|"dialog"
Announces that the button opens a popup, and of what kind, via aria-haspopup. true is equivalent to "menu".MDNaria-haspopup
ariaControlsstring
Id of the element this button controls (e.g. the popup or panel it expands), forwarded to aria-controls.MDNaria-controls
variant"default"|"secondary"|"destructive"|"outline"|"ghost"|"link""default"
Visual style variant.
size"xs"|"sm"|"default"|"lg"|"icon"|"icon-xs"|"icon-sm"|"icon-lg""default"
Size variant. icon-* sizes are square with no horizontal padding.
type"button"|"submit"|"reset""button"
Submit / reset semantics when nested in a form.MDN<button type>
disabledbooleanfalse
Disable the button — skipped from tab order, no click handling.
autofocusbooleanfalse
Focus this button on initial page load (one per document).
formaction / formenctype / formmethod / formnovalidate / formtargetstring
Per-button overrides for <form> attributes when this button submits.MDN<button> form-overrides
popovertarget / popovertargetactionstring
Open / hide / toggle a [popover] element by id. Zero-JS popover trigger.MDNpopovertarget
command / commandforstring
Newer Invoker API — declarative show-modal / close / show-popover / toggle-popover.MDN<button command>
asChildbooleanfalse
Render the wrapped child instead of a <button>, merging button classes onto it. Useful to render an <a> styled like a button.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference