shshadcn-htmx

Components

Checkbox

A real <input type="checkbox"> styled with shadcn polish. The native input keeps form submission and keyboard interaction; we layer the check and dash icons on top with Tailwind's peer-* variants.

Installation

Pair with the Label component for the click-target / accessible-name binding.

1. Install via the shadcn CLI

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

2. Use it

components/ui/checkbox.tsx
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"

<div class="flex items-center gap-2">
  <Checkbox id="terms" name="terms" required />
  <Label htmlFor="terms">I agree to the terms.</Label>
</div>
Or copy the source manually
components/ui/checkbox.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// Native <input type="checkbox"> with shadcn polish. Source of truth:
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/checkbox.tsx
//
// The upstream shadcn uses Radix Checkbox, which renders a styled div with
// role="checkbox" and hides the native input. We can't depend on Radix in
// our SSR setup, so we keep a real <input type="checkbox"> (form-submittable,
// keyboard-accessible, accessible-name aware) and layer a check icon on top.
//
// Pairing with a Label: use the htmlFor pattern — clicking the label toggles
// the checkbox. Indeterminate state must be set via JS (the HTML attribute
// alone won't do it); we mirror the data-state attribute on the wrapper so
// custom styling can react.

const inputBase =
  "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "disabled:cursor-not-allowed disabled:opacity-50 " +
  "checked:border-primary checked:bg-primary " +
  "indeterminate:border-primary indeterminate:bg-primary " +
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
  "dark:bg-input/30"

export function checkboxClasses(opts?: { class?: ClassValue }): string {
  return cn(inputBase, opts?.class)
}

type CheckboxProps = {
  id?: string
  name?: string
  value?: string
  checked?: boolean
  defaultChecked?: boolean
  // Render the SSR hook for an initially-indeterminate checkbox. `indeterminate`
  // is an IDL-only property with no HTML content attribute (WHATWG HTML: it is
  // initially false and "cannot be set using an HTML attribute"), so an SSR
  // component emits data-initial-indeterminate="true" and a tiny on-mount
  // script flips el.indeterminate. See repos/whatwg-html/source (indeterminate
  // IDL attribute) and the input/checkbox MDN reference.
  indeterminate?: boolean
  required?: boolean
  disabled?: boolean
  readonly?: boolean
  form?: string
  class?: ClassValue
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  ariaInvalid?: boolean
  // ID of a visible element containing an error message. Pair with
  // ariaInvalid for the full WCAG error-identification pattern.
  // See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-errormessage/index.md
  ariaErrormessage?: string
  // ARIA flag for non-editable checkboxes. Unlike HTML readonly (not valid
  // on checkbox), aria-readonly keeps the element focusable + announces it
  // as read-only. Use when you want the user to land on the box and hear
  // *why* it's locked. See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-readonly/index.md
  ariaReadonly?: boolean
  // Tri-state semantic. The DOM property `indeterminate` controls the
  // visual ::indeterminate pseudo (we set it from JS in docs); this prop
  // surfaces the same to assistive tech via aria-checked="mixed".
  // See repos/mdn/files/en-us/web/accessibility/aria/reference/roles/checkbox_role/index.md:30,66-67
  ariaChecked?: boolean | "mixed"
  // If this checkbox toggles visibility/state of other UI, point at the
  // controlled element's id.
  ariaControls?: string

  // htmx — fire on the input's change event. Useful for "save on toggle"
  // patterns where the server records the new state and may return updated
  // UI (e.g. a row swap).
  "hx-get"?: string
  "hx-post"?: string
  "hx-put"?: string
  "hx-patch"?: string
  "hx-target"?: string
  "hx-swap"?: string
  "hx-trigger"?: string
  "hx-vals"?: string
  "hx-include"?: string
}

export function Checkbox(props: CheckboxProps) {
  const {
    class: className,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    ariaInvalid,
    ariaErrormessage,
    ariaReadonly,
    ariaChecked,
    ariaControls,
    checked,
    defaultChecked,
    indeterminate,
    ...rest
  } = props

  return (
    <span class="relative inline-flex size-4 shrink-0 align-middle">
      <input
        type="checkbox"
        class={checkboxClasses({ class: className })}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
        aria-errormessage={ariaErrormessage}
        aria-readonly={ariaReadonly === undefined ? undefined : String(ariaReadonly)}
        aria-checked={ariaChecked === undefined ? undefined : String(ariaChecked)}
        aria-controls={ariaControls}
        data-slot="checkbox"
        // Hono JSX does not map defaultChecked -> checked like React. Resolve it
        // here so the prop is real: explicit `checked` wins, then `defaultChecked`,
        // and `false` serializes to no attribute (never an invalid defaultChecked="...").
        checked={(checked ?? defaultChecked) || undefined}
        // indeterminate has no HTML content attribute; emit the on-mount hook
        // a script reads to set el.indeterminate. See repos/whatwg-html/source.
        data-initial-indeterminate={indeterminate ? "true" : undefined}
        {...rest}
      />
      {/* Check icon — visible only when peer (the input) is :checked. */}
      <svg
        class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="3"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <polyline points="20 6 9 17 4 12" />
      </svg>
      {/* Dash icon for the :indeterminate state. */}
      <svg
        class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="3"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <line x1="5" y1="12" x2="19" y2="12" />
      </svg>
    </span>
  )
}

1. Save the file

Copy checkbox.html into templates/components/.

2. Use it

templates/components/checkbox.html
{% from "components/checkbox.html" import checkbox %}
{% from "components/label.html" import label %}

<div class="flex items-center gap-2">
  {{ checkbox(id="terms", name="terms", required=true) }}
  {{ label("I agree to the terms.", for_="terms") }}
</div>
View source
templates/components/checkbox.html
{# Checkbox macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/checkbox.tsx. The macro renders a native
   <input type="checkbox"> wrapped in a span so a check/dash SVG can layer
   on top via Tailwind's peer-checked / peer-indeterminate variants.

   Usage:
       {% from "components/checkbox.html" import checkbox %}
       <label class="flex items-center gap-2">
         {{ checkbox(id="terms", name="terms", required=true) }}
         I agree to the terms.
       </label> #}

{% macro checkbox(
    id=none,
    name=none,
    value=none,
    checked=false,
    indeterminate=false,
    required=false,
    disabled=false,
    readonly=false,
    form=none,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    aria_invalid=none,
    extra_class="",
    **attrs
) %}
{%- set base -%}
peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30
{%- endset -%}
<span class="relative inline-flex size-4 shrink-0 align-middle">
  <input type="checkbox"
         class="{{ base }} {{ extra_class }}"
         {%- if id %} id="{{ id }}"{% endif %}
         {%- if name %} name="{{ name }}"{% endif %}
         {%- if value is not none %} value="{{ value }}"{% endif %}
         {%- if checked %} checked{% endif %}
         {#- indeterminate is IDL-only (no HTML attribute per WHATWG HTML); emit
             the on-mount hook a script reads to set el.indeterminate. #}
         {%- if indeterminate %} data-initial-indeterminate="true"{% endif %}
         {%- if required %} required{% endif %}
         {%- if disabled %} disabled{% endif %}
         {%- if readonly %} readonly{% endif %}
         {%- if form %} form="{{ form }}"{% 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 %}
         {%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
         data-slot="checkbox"
         {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
  >
  <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
       viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <polyline points="20 6 9 17 4 12" />
  </svg>
  <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
       viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <line x1="5" y1="12" x2="19" y2="12" />
  </svg>
</span>
{% endmacro %}

1. Save the file

Add checkbox.tmpl alongside button.tmpl.

2. Use it

templates/components/checkbox.tmpl
{{template "checkbox" (dict "ID" "terms" "Name" "terms" "Required" true)}}
{{template "label"    (dict "For" "terms" "Text" "I agree to the terms.")}}
View source
templates/components/checkbox.tmpl
{{/*
  Checkbox template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/checkbox.tsx.

  Usage:

      type CheckboxArgs struct {
          ID, Name, Value string
          Checked, Indeterminate, Required, Disabled, Readonly bool
          Form, AriaLabel, AriaLabelledby, AriaDescribedby string
          AriaInvalid string // "true" | "false"
          Attrs map[string]string
      }

      tpl.ExecuteTemplate(w, "checkbox", CheckboxArgs{
          ID: "terms", Name: "terms", Required: true,
      })
*/}}

{{define "checkbox"}}
{{- $base := "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" -}}
<span class="relative inline-flex size-4 shrink-0 align-middle">
  <input type="checkbox" class="{{$base}}"
         {{- if .ID}} id="{{.ID}}"{{end}}
         {{- if .Name}} name="{{.Name}}"{{end}}
         {{- if .Value}} value="{{.Value}}"{{end}}
         {{- if .Checked}} checked{{end}}
         {{- /* indeterminate is IDL-only (no HTML attribute per WHATWG HTML);
                emit the on-mount hook a script reads to set el.indeterminate. */}}
         {{- if .Indeterminate}} data-initial-indeterminate="true"{{end}}
         {{- if .Required}} required{{end}}
         {{- if .Disabled}} disabled{{end}}
         {{- if .Readonly}} readonly{{end}}
         {{- if .Form}} form="{{.Form}}"{{end}}
         {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
         {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
         {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
         {{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
         data-slot="checkbox"
         {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
  >
  <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
       viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <polyline points="20 6 9 17 4 12" />
  </svg>
  <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
       viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <line x1="5" y1="12" x2="19" y2="12" />
  </svg>
</span>
{{end}}

1. Save the file

Drop checkbox.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/checkbox.ex
<div class="flex items-center gap-2">
  <.checkbox id="terms" name="terms" required />
  <.label for="terms">I agree to the terms.</.label>
</div>
View source
lib/my_app_web/components/checkbox.ex
defmodule ShadcnHtmx.Components.Checkbox do
  @moduledoc """
  Checkbox — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Native `<input type="checkbox">` styled with shadcn polish. Pair with the
  Label component for the click-target / accessible-name pattern.

  ## Examples

      <label class="flex items-center gap-2">
        <.checkbox name="terms" required />
        I agree to the terms.
      </label>

      <.checkbox name="newsletter" checked
        hx-post="/preferences/newsletter" hx-trigger="change" />
  """

  use Phoenix.Component

  @input_base "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs " <>
                "transition-shadow outline-none " <>
                "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
                "disabled:cursor-not-allowed disabled:opacity-50 " <>
                "checked:border-primary checked:bg-primary " <>
                "indeterminate:border-primary indeterminate:bg-primary " <>
                "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
                "dark:bg-input/30"

  attr :class, :string, default: nil

  # `indeterminate` is an IDL-only property — no HTML content attribute exists
  # for it (WHATWG HTML: initially false, "cannot be set using an HTML
  # attribute"). Emit data-initial-indeterminate="true" so an on-mount script
  # can set el.indeterminate. See repos/whatwg-html/source.
  attr :indeterminate, :boolean, default: false

  attr :rest, :global,
    include:
      ~w(hx-get hx-post hx-put hx-patch hx-target hx-swap hx-trigger hx-vals hx-include
         id name value checked required disabled readonly form
         aria-label aria-labelledby aria-describedby aria-invalid)

  def checkbox(assigns) do
    assigns = assign(assigns, :input_base, @input_base)

    ~H"""
    <span class="relative inline-flex size-4 shrink-0 align-middle">
      <input
        type="checkbox"
        class={[@input_base, @class]}
        data-slot="checkbox"
        data-initial-indeterminate={@indeterminate && "true"}
        {@rest}
      />
      <svg
        class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="3"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <polyline points="20 6 9 17 4 12" />
      </svg>
      <svg
        class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="3"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <line x1="5" y1="12" x2="19" y2="12" />
      </svg>
    </span>
    """
  end
end

1. Save the file

Tailwind v4 + the SVG icons inline. No extra script for the check; indeterminate state needs a one-liner of JS.

2. Use it

index.html
<label for="terms" class="flex items-center gap-2 text-sm font-medium">
  <span class="relative inline-flex size-4">
    <input id="terms" name="terms" type="checkbox" required class="peer …" />
    <svg class="… peer-checked:block">…</svg>
  </span>
  I agree to the terms.
</label>
View source
index.html
<!--
  shadcn-htmx — raw HTML checkbox snippets.

  Mirrors registry/ui/checkbox.tsx. The visual checkmark and dash are SVGs
  layered on top of a real <input type="checkbox">. Tailwind's peer-checked
  and peer-indeterminate variants show the right icon for the right state.

  Indeterminate state cannot be set from HTML alone — set
  `checkboxEl.indeterminate = true` from JavaScript. The styles below assume
  the browser's :indeterminate pseudo is active.

  BASE (shared by every input):
    peer size-4 shrink-0 appearance-none rounded-[4px] border border-input
    bg-background shadow-xs transition-shadow outline-none
    focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
    disabled:cursor-not-allowed disabled:opacity-50
    checked:border-primary checked:bg-primary
    indeterminate:border-primary indeterminate:bg-primary
    aria-invalid:border-destructive aria-invalid:ring-destructive/20
    dark:aria-invalid:ring-destructive/40 dark:bg-input/30
-->

<!-- Basic (with explicit Label pairing) -->
<label for="terms" class="flex items-center gap-2 text-sm font-medium leading-none select-none">
  <span class="relative inline-flex size-4 shrink-0 align-middle">
    <input id="terms" name="terms" type="checkbox" required data-slot="checkbox"
      class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30">
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
         viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12" />
    </svg>
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
         viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <line x1="5" y1="12" x2="19" y2="12" />
    </svg>
  </span>
  I agree to the terms.
</label>

<!-- Checked by default -->
<label class="flex items-center gap-2 text-sm font-medium">
  <span class="relative inline-flex size-4">
    <input type="checkbox" name="newsletter" checked data-slot="checkbox"
      class="peer size-4 appearance-none rounded-[4px] border border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 checked:border-primary checked:bg-primary dark:bg-input/30">
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
         viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12" />
    </svg>
  </span>
  Subscribe to the newsletter
</label>

<!-- Disabled -->
<label class="flex items-center gap-2 text-sm font-medium leading-none select-none peer-disabled:opacity-50">
  <span class="relative inline-flex size-4">
    <input type="checkbox" disabled data-slot="checkbox"
      class="peer size-4 appearance-none rounded-[4px] border border-input bg-background shadow-xs disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary dark:bg-input/30">
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
         viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12" />
    </svg>
  </span>
  Disabled option
</label>

<!-- Indeterminate — IDL-only property, no HTML attribute (WHATWG HTML: initially
     false, "cannot be set using an HTML attribute"). Mark the input with the
     data-initial-indeterminate hook and let an on-mount script flip it; this is
     the same hook the framework flavours emit from an `indeterminate` prop. -->
<label class="flex items-center gap-2 text-sm font-medium">
  <span class="relative inline-flex size-4">
    <input id="select-all" type="checkbox" data-slot="checkbox" data-initial-indeterminate="true"
      class="peer size-4 appearance-none rounded-[4px] border border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30">
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
         viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12" />
    </svg>
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
         viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <line x1="5" y1="12" x2="19" y2="12" />
    </svg>
  </span>
  Select all
</label>
<script>
  document.querySelectorAll('[data-initial-indeterminate="true"]').forEach(function (el) { el.indeterminate = true })
</script>

<!-- htmx — save on toggle -->
<label class="flex items-center gap-2 text-sm font-medium">
  <span class="relative inline-flex size-4">
    <input type="checkbox" name="favorite" data-slot="checkbox"
           hx-post="/items/42/favorite" hx-trigger="change" hx-swap="none"
      class="peer size-4 appearance-none rounded-[4px] border border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 checked:border-primary checked:bg-primary dark:bg-input/30">
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
         viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12" />
    </svg>
  </span>
  Favourite
</label>

Examples

Basic — pair with a Label

Click the label or the checkbox itself — the platform routes both to the same input.

Always pair a checkbox with a visible label. The accessible name comes from the linked <label>, not from a placeholder or surrounding text. Without it, screen readers announce just "checkbox, checked" — useless.

<div class="flex items-center gap-2">
  <Checkbox id="terms" name="terms" required />
  <Label htmlFor="terms">I agree to the terms.</Label>
</div>
<div class="flex items-center gap-2">
  {{ checkbox(id="terms", name="terms", required=true) }}
  {{ label("I agree to the terms.", for_="terms") }}
</div>
<div class="flex items-center gap-2">
  {{template "checkbox" (dict "ID" "terms" "Name" "terms" "Required" true)}}
  {{template "label"    (dict "For" "terms" "Text" "I agree to the terms.")}}
</div>
<div class="flex items-center gap-2">
  <.checkbox id="terms" name="terms" required />
  <.label for="terms">I agree to the terms.</.label>
</div>
<div class="flex items-center gap-2">
  <span class="relative inline-flex size-4 shrink-0 align-middle">
    <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" id="ex-basic-cb" name="terms"/>
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12">
      </polyline>
    </svg>
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <line x1="5" y1="12" x2="19" y2="12">
      </line>
    </svg>
  </span>
  <label for="ex-basic-cb" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">I agree to the terms.</label>
</div>

Checked + disabled + invalid

All states come from native attributes; no JavaScript needed.

checked pre-selects the box, disabled removes it from form submission, aria-invalid="true" flips the border to destructive. Pair invalid with an error message via aria-describedby — the same pattern as Input.

You need to accept this to continue.

<Checkbox checked />
<Checkbox disabled />
<Checkbox disabled checked />
<Checkbox ariaInvalid ariaDescribedby="my-err" />
{{ checkbox(checked=true) }}
{{ checkbox(disabled=true) }}
{{ checkbox(disabled=true, checked=true) }}
{{ checkbox(aria_invalid=true, aria_describedby="my-err") }}
{{template "checkbox" (dict "Checked" true)}}
{{template "checkbox" (dict "Disabled" true)}}
{{template "checkbox" (dict "Disabled" true "Checked" true)}}
{{template "checkbox" (dict "AriaInvalid" "true" "AriaDescribedby" "my-err")}}
<.checkbox checked />
<.checkbox disabled />
<.checkbox disabled checked />
<.checkbox aria-invalid="true" aria-describedby="my-err" />
<div class="grid gap-3">
  <div class="flex items-center gap-2">
    <span class="relative inline-flex size-4 shrink-0 align-middle">
      <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" checked="" id="ex-state-checked"/>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polyline points="20 6 9 17 4 12">
        </polyline>
      </svg>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <line x1="5" y1="12" x2="19" y2="12">
        </line>
      </svg>
    </span>
    <label for="ex-state-checked" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Pre-checked</label>
  </div>
  <div class="flex items-center gap-2">
    <span class="relative inline-flex size-4 shrink-0 align-middle">
      <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" id="ex-state-disabled" disabled=""/>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polyline points="20 6 9 17 4 12">
        </polyline>
      </svg>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <line x1="5" y1="12" x2="19" y2="12">
        </line>
      </svg>
    </span>
    <label for="ex-state-disabled" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Disabled</label>
  </div>
  <div class="flex items-center gap-2">
    <span class="relative inline-flex size-4 shrink-0 align-middle">
      <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" checked="" id="ex-state-disabled-checked" disabled=""/>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polyline points="20 6 9 17 4 12">
        </polyline>
      </svg>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <line x1="5" y1="12" x2="19" y2="12">
        </line>
      </svg>
    </span>
    <label for="ex-state-disabled-checked" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Disabled + checked</label>
  </div>
  <div class="flex items-center gap-2">
    <span class="relative inline-flex size-4 shrink-0 align-middle">
      <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" aria-describedby="ex-state-invalid-err" aria-invalid="true" data-slot="checkbox" id="ex-state-invalid"/>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polyline points="20 6 9 17 4 12">
        </polyline>
      </svg>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <line x1="5" y1="12" x2="19" y2="12">
        </line>
      </svg>
    </span>
    <label for="ex-state-invalid" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Required option</label>
  </div>
  <p id="ex-state-invalid-err" class="-mt-1 text-xs text-destructive">You need to accept this to continue.</p>
</div>

Further reading

Indeterminate — set from JS

Indeterminate is a property, not an HTML attribute. The page sets it on mount; clicking it picks a definite state.

Indeterminate doesn't exist as an HTML attribute — it's a DOM property you set in JavaScript (the page below does: el.indeterminate = true). Use it for tri-state UIs: the classic "select all" row that's neither fully checked nor fully unchecked.

<Checkbox id="select-all" /* set indeterminate=true from JS once mounted */ />
{{ checkbox(id="select-all") }}
<script>document.getElementById('select-all').indeterminate = true</script>
{{template "checkbox" (dict "ID" "select-all")}}
<script>document.getElementById('select-all').indeterminate = true</script>
<.checkbox id="select-all" phx-hook="MarkIndeterminate" />
<div class="flex flex-col gap-3">
  <div class="flex items-center gap-2">
    <span class="relative inline-flex size-4 shrink-0 align-middle">
      <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" data-initial-indeterminate="true" id="ex-ind-parent"/>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polyline points="20 6 9 17 4 12">
        </polyline>
      </svg>
      <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <line x1="5" y1="12" x2="19" y2="12">
        </line>
      </svg>
    </span>
    <label for="ex-ind-parent" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Select all (3 items, 1 selected)</label>
  </div>
  <div class="ml-6 flex flex-col gap-2">
    <div class="flex items-center gap-2">
      <span class="relative inline-flex size-4 shrink-0 align-middle">
        <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" checked="" id="ex-ind-child-1"/>
        <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polyline points="20 6 9 17 4 12">
          </polyline>
        </svg>
        <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <line x1="5" y1="12" x2="19" y2="12">
          </line>
        </svg>
      </span>
      <label for="ex-ind-child-1" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Item 1</label>
    </div>
    <div class="flex items-center gap-2">
      <span class="relative inline-flex size-4 shrink-0 align-middle">
        <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" id="ex-ind-child-2"/>
        <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polyline points="20 6 9 17 4 12">
          </polyline>
        </svg>
        <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <line x1="5" y1="12" x2="19" y2="12">
          </line>
        </svg>
      </span>
      <label for="ex-ind-child-2" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Item 2</label>
    </div>
    <div class="flex items-center gap-2">
      <span class="relative inline-flex size-4 shrink-0 align-middle">
        <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" id="ex-ind-child-3"/>
        <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polyline points="20 6 9 17 4 12">
          </polyline>
        </svg>
        <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <line x1="5" y1="12" x2="19" y2="12">
          </line>
        </svg>
      </span>
      <label for="ex-ind-child-3" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Item 3</label>
    </div>
  </div>
</div>

htmx — save on toggle

hx-trigger="change" posts the new state on every toggle. The endpoint records it and (here) returns 204.

For "save on every click" controls (favourite, follow, mute) fire on change and use hx-swap="none" when the server doesn't need to return new UI. If you do return HTML, swap the row outerHTML so a pending icon or count can update in lockstep.

<Checkbox name="favorite"
          hx-post="/api/favorite" hx-trigger="change" hx-swap="none" />
{{ checkbox(name="favorite",
            hx_post="/api/favorite", hx_trigger="change", hx_swap="none") }}
{{template "checkbox" (dict
  "Name" "favorite"
  "Attrs" (dict "hx-post" "/api/favorite" "hx-trigger" "change" "hx-swap" "none")
)}}
<.checkbox name="favorite"
            hx-post="/api/favorite" hx-trigger="change" hx-swap="none" />
<div class="flex items-center gap-2">
  <span class="relative inline-flex size-4 shrink-0 align-middle">
    <input type="checkbox" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="checkbox" id="ex-htmx-fav" name="favorite" hx-post="/checkbox/save" hx-trigger="change" hx-swap="none"/>
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <polyline points="20 6 9 17 4 12">
      </polyline>
    </svg>
    <svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <line x1="5" y1="12" x2="19" y2="12">
      </line>
    </svg>
  </span>
  <label for="ex-htmx-fav" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Favourite (saves on every toggle)</label>
</div>

API Reference

<Checkbox>

PropTypeDefaultDescription
indeterminateboolean
Render data-initial-indeterminate="true" on the input so an on-mount script can set the IDL-only el.indeterminate (no HTML attribute exists for it per WHATWG HTML). Use for tri-state select-all rows.
checkedboolean
Controlled checked state.
defaultCheckedboolean
Initial checked state at render.
indeterminateboolean
Tri-state — set via JS DOM property after mount.
ariaChecked"true"|"false"|"mixed"
Override aria-checked for non-native variants.
ariaReadonlyboolean
Mark as read-only without disabling focus.
ariaErrormessagestring
Id of a visible error message element.
idstring
Pairs the input with a <label for>.
namestring
Form field name on submit.
requiredbooleanfalse
Native HTML required for form validation.
disabledbooleanfalse
Disable — unfocusable, not submitted.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference