shshadcn-htmx

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

2. Use it

components/ui/switch.tsx
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
components/ui/switch.tsx
/** @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

templates/components/switch.html
{% 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
templates/components/switch.html
{# 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

templates/components/switch.tmpl
{{template "switch" (dict "ID" "notifications" "Name" "notifications")}}
{{template "label"  (dict "For" "notifications" "Text" "Enable notifications")}}
View source
templates/components/switch.tmpl
{{/*
  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

lib/my_app_web/components/switch.ex
<div class="flex items-center gap-2">
  <.switch id="notifications" name="notifications" />
  <.label for="notifications">Enable notifications</.label>
</div>
View source
lib/my_app_web/components/switch.ex
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

index.html
<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
index.html
<!--
  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>

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>

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>

API Reference

<Switch>

PropTypeDefaultDescription
autofocusboolean
Focus this switch on initial page load (one per document).
size"default"|"sm""default"
Visual size variant.
checkedboolean
Controlled checked state.
ariaReadonlyboolean
Read-only switch — focusable but non-toggleable.
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