Components
Switch
A native <input type="checkbox" role="switch"> styled as a sliding pill. Form-submittable, keyboard-toggleable (Space), accessible name from a paired <label>.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/switch.json2. Use it
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
<div class="flex items-center gap-2">
<Switch id="notifications" name="notifications" />
<Label htmlFor="notifications">Enable notifications</Label>
</div>Or copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Switch — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth:
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/switch.tsx
//
// Upstream uses Radix Switch (a div with role="switch"). We use a native
// <input type="checkbox" role="switch"> so the value is form-submittable,
// the platform handles keyboard activation (Space toggles), and the
// accessible name comes from a linked <label>.
//
// APG: WAI-ARIA Switch pattern
// repos/aria-practices/content/patterns/switch/
export type SwitchSize = "sm" | "default"
const trackBase =
"relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle"
const trackSize: Record<SwitchSize, string> = {
default: "h-[1.15rem] w-8",
sm: "h-3.5 w-6",
}
const inputBase =
"peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors " +
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"disabled:cursor-not-allowed disabled:opacity-50 " +
"bg-input dark:bg-input/80 checked:bg-primary"
const thumbBase =
"pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform " +
"peer-checked:translate-x-[calc(100%-2px)] " +
"dark:peer-checked:bg-primary-foreground dark:bg-foreground"
const thumbSize: Record<SwitchSize, string> = {
default: "size-4",
sm: "size-3",
}
export function switchClasses(opts?: {
size?: SwitchSize
class?: ClassValue
}): string {
return cn(trackBase, trackSize[opts?.size ?? "default"], opts?.class)
}
type SwitchProps = {
id?: string
name?: string
value?: string
checked?: boolean
required?: boolean
disabled?: boolean
readonly?: boolean
size?: SwitchSize
form?: string
// Focus this switch on initial page load (one per document). Global
// attribute valid on <input type="checkbox">.
// repos/mdn/files/en-us/web/html/reference/elements/input/index.md:410-423
autofocus?: boolean
class?: ClassValue
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
// Linked error message id (pair with aria-invalid on the input).
ariaErrormessage?: string
ariaInvalid?: boolean
// Read-only state for non-native variants. APG switch role explicitly
// supports aria-readonly. See
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/switch_role/index.md:63-64
ariaReadonly?: boolean
// Surface element this switch shows/hides (e.g. a "advanced settings"
// section that's only visible when on).
ariaControls?: string
// htmx — fire on toggle to persist the change.
"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 Switch(props: SwitchProps) {
const {
size = "default",
class: className,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
ariaErrormessage,
ariaInvalid,
ariaReadonly,
ariaControls,
...rest
} = props
return (
<span
data-slot="switch"
data-size={size}
class={switchClasses({ size, class: className })}
>
<input
type="checkbox"
role="switch"
class={inputBase}
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-controls={ariaControls}
{...rest}
/>
<span class={cn(thumbBase, thumbSize[size])} data-slot="switch-thumb" aria-hidden="true" />
</span>
)
}
1. Save the file
Copy switch.html into templates/components/.
2. Use it
{% from "components/switch.html" import switch %}
{% from "components/label.html" import label %}
<div class="flex items-center gap-2">
{{ switch(id="notifications", name="notifications") }}
{{ label("Enable notifications", for_="notifications") }}
</div>View source
{# Switch macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/switch.tsx. Native <input type="checkbox" role="switch">
for form submission + platform keyboard contract.
Usage:
{% from "components/switch.html" import switch %}
<label class="flex items-center gap-2">
{{ switch(id="notifications", name="notifications", checked=true) }}
Enable notifications
</label> #}
{% macro switch(
id=none,
name=none,
value=none,
checked=false,
required=false,
disabled=false,
readonly=false,
autofocus=false,
size="default",
form=none,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
extra_class="",
**attrs
) %}
{%- set track_base -%}
relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle
{%- endset -%}
{%- set track_sizes = {
"default": "h-[1.15rem] w-8",
"sm": "h-3.5 w-6"
} -%}
{%- set thumb_sizes = {
"default": "size-4",
"sm": "size-3"
} -%}
{%- set input_base -%}
peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary
{%- endset -%}
{%- set thumb_base -%}
pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground
{%- endset -%}
<span data-slot="switch" data-size="{{ size }}"
class="{{ track_base }} {{ track_sizes[size] }} {{ extra_class }}">
<input type="checkbox" role="switch"
class="{{ input_base }}"
{%- if id %} id="{{ id }}"{% endif %}
{%- if name %} name="{{ name }}"{% endif %}
{%- if value is not none %} value="{{ value }}"{% endif %}
{%- if checked %} checked{% endif %}
{%- if required %} required{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if readonly %} readonly{% endif %}
{# autofocus: global attr valid on input — MDN input reference #}
{%- if autofocus %} autofocus{% 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 %}
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
<span class="{{ thumb_base }} {{ thumb_sizes[size] }}" data-slot="switch-thumb" aria-hidden="true"></span>
</span>
{% endmacro %}
1. Save the file
Add switch.tmpl alongside button.tmpl.
2. Use it
{{template "switch" (dict "ID" "notifications" "Name" "notifications")}}
{{template "label" (dict "For" "notifications" "Text" "Enable notifications")}}View source
{{/*
Switch template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/switch.tsx.
Usage:
type SwitchArgs struct {
ID, Name, Value, Size string // "default" | "sm"
Checked, Required, Disabled, Readonly, Autofocus bool
Form, AriaLabel, AriaLabelledby, AriaDescribedby string
Attrs map[string]string
}
*/}}
{{define "switch"}}
{{- $size := or .Size "default" -}}
{{- $trackBase := "relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle" -}}
{{- $trackSize := index (dict "default" "h-[1.15rem] w-8" "sm" "h-3.5 w-6") $size -}}
{{- $thumbSize := index (dict "default" "size-4" "sm" "size-3") $size -}}
{{- $inputBase := "peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" -}}
{{- $thumbBase := "pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground" -}}
<span data-slot="switch" data-size="{{$size}}" class="{{$trackBase}} {{$trackSize}}">
<input type="checkbox" role="switch" class="{{$inputBase}}"
{{- if .ID}} id="{{.ID}}"{{end}}
{{- if .Name}} name="{{.Name}}"{{end}}
{{- if .Value}} value="{{.Value}}"{{end}}
{{- if .Checked}} checked{{end}}
{{- if .Required}} required{{end}}
{{- if .Disabled}} disabled{{end}}
{{- if .Readonly}} readonly{{end}}
{{/* autofocus: global attr valid on input — MDN input reference */}}
{{- if .Autofocus}} autofocus{{end}}
{{- if .Form}} form="{{.Form}}"{{end}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
<span class="{{$thumbBase}} {{$thumbSize}}" data-slot="switch-thumb" aria-hidden="true"></span>
</span>
{{end}}
1. Save the file
Drop switch.ex into lib/my_app_web/components/.
2. Use it
<div class="flex items-center gap-2">
<.switch id="notifications" name="notifications" />
<.label for="notifications">Enable notifications</.label>
</div>View source
defmodule ShadcnHtmx.Components.Switch do
@moduledoc """
Switch — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Native `<input type="checkbox" role="switch">` styled as a sliding pill.
Form-submittable, keyboard-toggleable (Space), accessible name from a
linked `<label>`.
See repos/aria-practices/content/patterns/switch/.
## Examples
<label class="flex items-center gap-2">
<.switch name="notifications" checked />
Enable notifications
</label>
<.switch name="favorite" size="sm"
hx-post="/items/42/favorite" hx-trigger="change" hx-swap="none" />
"""
use Phoenix.Component
@track_base "relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle"
@track_sizes %{"default" => "h-[1.15rem] w-8", "sm" => "h-3.5 w-6"}
@thumb_sizes %{"default" => "size-4", "sm" => "size-3"}
@input_base "peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors " <>
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"disabled:cursor-not-allowed disabled:opacity-50 " <>
"bg-input dark:bg-input/80 checked:bg-primary"
@thumb_base "pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform " <>
"peer-checked:translate-x-[calc(100%-2px)] " <>
"dark:peer-checked:bg-primary-foreground dark:bg-foreground"
attr :size, :string, default: "default", values: ~w(default sm)
attr :class, :string, default: nil
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 autofocus form
aria-label aria-labelledby aria-describedby)
def switch(assigns) do
assigns =
assigns
|> assign(:track_class, Map.fetch!(@track_sizes, assigns.size))
|> assign(:thumb_class, Map.fetch!(@thumb_sizes, assigns.size))
|> assign(:track_base, @track_base)
|> assign(:input_base, @input_base)
|> assign(:thumb_base, @thumb_base)
~H"""
<span
data-slot="switch"
data-size={@size}
class={[@track_base, @track_class, @class]}
>
<input type="checkbox" role="switch" class={@input_base} {@rest} />
<span class={[@thumb_base, @thumb_class]} data-slot="switch-thumb" aria-hidden="true"></span>
</span>
"""
end
end
1. Save the file
Tailwind utilities only — no extra script.
2. Use it
<label for="notifications" class="flex items-center gap-2">
<span class="relative inline-flex h-[1.15rem] w-8 …">
<input type="checkbox" role="switch" id="notifications" class="peer …">
<span class="… peer-checked:translate-x-[calc(100%-2px)]"></span>
</span>
Enable notifications
</label>View source
<!--
shadcn-htmx — raw HTML switch snippets.
Mirrors registry/ui/switch.tsx. Native <input type="checkbox" role="switch">
+ a thumb span on top, all styled with Tailwind utilities. No JS required.
The <input> accepts any valid checkbox attribute. To focus a switch on
initial page load (one per document) add the global `autofocus` attribute.
See repos/mdn/files/en-us/web/html/reference/elements/input/index.md:410-423
TRACK base: relative inline-flex shrink-0 items-center rounded-full border
border-transparent shadow-xs transition-all align-middle
INPUT base: peer absolute inset-0 size-full cursor-pointer appearance-none
rounded-full outline-none transition-colors
focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
disabled:cursor-not-allowed disabled:opacity-50
bg-input dark:bg-input/80 checked:bg-primary
THUMB base: pointer-events-none absolute top-1/2 left-px -translate-y-1/2
rounded-full bg-background transition-transform
peer-checked:translate-x-[calc(100%-2px)]
dark:peer-checked:bg-primary-foreground dark:bg-foreground
-->
<!-- Basic with label -->
<label for="notifications" class="flex items-center gap-2 text-sm font-medium">
<span data-slot="switch" data-size="default"
class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-[1.15rem] w-8">
<input id="notifications" name="notifications" type="checkbox" role="switch" checked
class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary">
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-4" data-slot="switch-thumb" aria-hidden="true"></span>
</span>
Enable notifications
</label>
<!-- Small variant -->
<label for="compact" class="flex items-center gap-2 text-sm">
<span data-slot="switch" data-size="sm"
class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-3.5 w-6">
<input id="compact" name="compact" type="checkbox" role="switch"
class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 bg-input dark:bg-input/80 checked:bg-primary">
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-3" aria-hidden="true"></span>
</span>
Compact mode
</label>
<!-- Disabled (off) -->
<label class="flex items-center gap-2 text-sm peer-disabled:opacity-50">
<span class="relative inline-flex shrink-0 items-center rounded-full border border-transparent h-[1.15rem] w-8">
<input type="checkbox" role="switch" disabled
class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full bg-input disabled:cursor-not-allowed disabled:opacity-50">
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 size-4 rounded-full bg-background" aria-hidden="true"></span>
</span>
Disabled
</label>
<!-- htmx — save on toggle -->
<label class="flex items-center gap-2 text-sm">
<span class="relative inline-flex shrink-0 items-center rounded-full border border-transparent h-[1.15rem] w-8">
<input type="checkbox" role="switch" name="favorite"
hx-post="/items/42/favorite" hx-trigger="change" hx-swap="none"
class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 bg-input checked:bg-primary">
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 size-4 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)]" aria-hidden="true"></span>
</span>
Favourite (saves on toggle)
</label>
Examples
Basic — Switch + Label
Pair with a Label exactly like Checkbox. Click anywhere on the label or tap Space when focused to flip the state.
APG's switch pattern is "a two-state button" — but unlike a toggle aria-pressed button, the switch has an explicit on/off label baked into the role. Use it for settings ("Enable notifications") where the label describes the setting, not the action.
<Switch id="notifications" name="notifications" />
<Label htmlFor="notifications">Enable notifications</Label>{{ switch(id="notifications", name="notifications") }}
{{ label("Enable notifications", for_="notifications") }}{{template "switch" (dict "ID" "notifications" "Name" "notifications")}}
{{template "label" (dict "For" "notifications" "Text" "Enable notifications")}}<.switch id="notifications" name="notifications" />
<.label for="notifications">Enable notifications</.label><div class="flex items-center gap-2">
<span data-slot="switch" data-size="default" class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-[1.15rem] w-8">
<input type="checkbox" role="switch" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" id="ex-switch-basic" name="notifications"/>
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-4" data-slot="switch-thumb" aria-hidden="true">
</span>
</span>
<label for="ex-switch-basic" 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">Enable notifications</label>
</div>Further reading
Sizes — default and sm
Two sizes: default (h-[1.15rem] w-8) and sm (h-3.5 w-6). Tighter rows in dense lists call for sm.
Touch targets matter — even when the visual is small, the hit area should be 24px+. Tailwind's size-3.5 height stays comfortable because the input fills the parent's full 24×16 region; you can tap anywhere on the pill.
<Switch /> // default
<Switch size="sm" /> // small{{ switch() }}
{{ switch(size="sm") }}{{template "switch" (dict)}}
{{template "switch" (dict "Size" "sm")}}<.switch />
<.switch size="sm" /><div class="flex flex-col items-start gap-3">
<div class="flex items-center gap-2">
<span data-slot="switch" data-size="default" class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-[1.15rem] w-8">
<input type="checkbox" role="switch" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" id="ex-switch-default"/>
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-4" data-slot="switch-thumb" aria-hidden="true">
</span>
</span>
<label for="ex-switch-default" 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">Default size</label>
</div>
<div class="flex items-center gap-2">
<span data-slot="switch" data-size="sm" class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-3.5 w-6">
<input type="checkbox" role="switch" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" id="ex-switch-sm"/>
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-3" data-slot="switch-thumb" aria-hidden="true">
</span>
</span>
<label for="ex-switch-sm" 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 text-xs" data-slot="label">Small size</label>
</div>
</div>Further reading
States — checked, disabled, invalid
Native attributes drive every state — same contract as Checkbox.
Pre-select with checked, block submission with disabled, surface a failure with aria-invalid="true". The thumb dims and the focus ring picks up disabled:opacity-50 automatically.
<Switch checked />
<Switch disabled />
<Switch disabled checked />{{ switch(checked=true) }}
{{ switch(disabled=true) }}
{{ switch(disabled=true, checked=true) }}{{template "switch" (dict "Checked" true)}}
{{template "switch" (dict "Disabled" true)}}
{{template "switch" (dict "Disabled" true "Checked" true)}}<.switch checked />
<.switch disabled />
<.switch disabled checked /><div class="grid gap-3">
<div class="flex items-center gap-2">
<span data-slot="switch" data-size="default" class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-[1.15rem] w-8">
<input type="checkbox" role="switch" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" id="ex-switch-pre" checked=""/>
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-4" data-slot="switch-thumb" aria-hidden="true">
</span>
</span>
<label for="ex-switch-pre" 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 data-slot="switch" data-size="default" class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-[1.15rem] w-8">
<input type="checkbox" role="switch" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" id="ex-switch-disabled" disabled=""/>
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-4" data-slot="switch-thumb" aria-hidden="true">
</span>
</span>
<label for="ex-switch-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 data-slot="switch" data-size="default" class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-[1.15rem] w-8">
<input type="checkbox" role="switch" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" id="ex-switch-disabled-on" disabled="" checked=""/>
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-4" data-slot="switch-thumb" aria-hidden="true">
</span>
</span>
<label for="ex-switch-disabled-on" 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>Further reading
htmx — save on toggle
Same recipe as Checkbox — hx-trigger="change" + hx-swap="none" persists the state without UI churn.
Switches almost always represent a setting the server tracks. Fire on change to record the new value, and use hx-swap="none" if the server only needs to record (no UI to swap). Return a fragment to update a status row in sync if you do need visual confirmation.
<Switch name="newsletter"
hx-post="/preferences/newsletter"
hx-trigger="change" hx-swap="none" />{{ switch(name="newsletter",
hx_post="/preferences/newsletter",
hx_trigger="change", hx_swap="none") }}{{template "switch" (dict
"Name" "newsletter"
"Attrs" (dict
"hx-post" "/preferences/newsletter"
"hx-trigger" "change"
"hx-swap" "none"
)
)}}<.switch name="newsletter"
hx-post={~p"/preferences/newsletter"}
hx-trigger="change" hx-swap="none" /><div class="flex items-center gap-2">
<span data-slot="switch" data-size="default" class="relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all align-middle h-[1.15rem] w-8">
<input type="checkbox" role="switch" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 bg-input dark:bg-input/80 checked:bg-primary" id="ex-switch-htmx" name="newsletter" hx-post="/switch/save" hx-trigger="change" hx-swap="none"/>
<span class="pointer-events-none absolute top-1/2 left-px -translate-y-1/2 rounded-full bg-background transition-transform peer-checked:translate-x-[calc(100%-2px)] dark:peer-checked:bg-primary-foreground dark:bg-foreground size-4" data-slot="switch-thumb" aria-hidden="true">
</span>
</span>
<label for="ex-switch-htmx" 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">Newsletter (saves on toggle)</label>
</div>Further reading
API Reference
<Switch>
| Prop | Type | Default | Description |
|---|---|---|---|
autofocus | boolean | — | Focus this switch on initial page load (one per document). |
size | "default"|"sm" | "default" | Visual size variant. |
checked | boolean | — | Controlled checked state. |
ariaReadonly | boolean | — | Read-only switch — focusable but non-toggleable. |
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 |