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.json2. Use it
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
/** @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
{% 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
{# 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
{{template "checkbox" (dict "ID" "terms" "Name" "terms" "Required" true)}}
{{template "label" (dict "For" "terms" "Text" "I agree to the terms.")}}View source
{{/*
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
<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
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
<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
<!--
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>Further reading
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>Further reading
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>Further reading
API Reference
<Checkbox>
| Prop | Type | Default | Description |
|---|---|---|---|
indeterminate | boolean | — | 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. |
checked | boolean | — | Controlled checked state. |
defaultChecked | boolean | — | Initial checked state at render. |
indeterminate | boolean | — | Tri-state — set via JS DOM property after mount. |
ariaChecked | "true"|"false"|"mixed" | — | Override aria-checked for non-native variants. |
ariaReadonly | boolean | — | Mark as read-only without disabling focus. |
ariaErrormessage | string | — | Id of a visible error message element. |
id | string | — | Pairs the input with a <label for>. |
name | string | — | Form field name on submit. |
required | boolean | false | Native HTML required for form validation. |
disabled | boolean | false | Disable — unfocusable, not submitted. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |