shshadcn-htmx

Components

Theme Toggle

A light / dark / system colour-scheme switcher. It honours the operating-system preference by default via prefers-color-scheme, and persists an explicit override in a cookie so the server re-renders the right theme with no flash. Rendered as a real radiogroup of native radios — three real states, native keyboard handling.

Installation

One file per stack. The visual control is pure server-rendered HTML; a tiny boot script (shipped in your site-wide site.js) reads and writes the cookie and toggles the .dark class.

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/theme-toggle.json

2. Use it

components/ui/theme-toggle.tsx
import { ThemeToggle } from "@/components/ui/theme-toggle"

// Read the cookie server-side so the right radio is checked with no flash.
<ThemeToggle value={cookies.theme ?? "system"} />
Or copy the source manually
components/ui/theme-toggle.tsx
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Theme Toggle — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A light / dark / system colour-scheme switcher. By default it honours the
// operating-system preference; an explicit choice is persisted in a cookie so
// the server can re-render the correct theme on the next request with NO
// flash of the wrong colours.
//
// Built on web-standard primitives — no framework theming runtime:
//   - prefers-color-scheme media feature (the "system" default):
//       repos/mdn/files/en-us/web/css/reference/at-rules/@media/prefers-color-scheme/index.md
//   - color-scheme property (so native form controls + scrollbars follow):
//       repos/mdn/files/en-us/web/css/reference/properties/color-scheme/index.md
//   - cookie persistence + a synchronous pre-paint boot script for no-flash,
//     adapted (NOT copied) from the web.dev theming patterns:
//       repos/web.dev/src/site/content/en/patterns/theming/theme-switch
//       repos/web.dev/src/site/content/en/patterns/theming/color-schemes
//     web.dev uses localStorage; we use a cookie so the *server* can read it
//     and render `.dark` up front — localStorage isn't available server-side,
//     which is why htmx/SSR apps prefer a cookie here.
//
// We model the three states as a native radio group rather than a 2-state
// button, because "system" is a real third choice — a toggle can't express
// it. Grouping native <input type="radio"> by `name` gives us arrow-key
// roving focus, single-selection, and aria-checked for free; only one option
// is selected at a time. The web.dev color-schemes pattern uses the same
// radio-form shape (assets/body.html). We layer the visual segmented control
// on top of appearance-none inputs via peer-checked, the way switch.tsx and
// radio-group.tsx do.
//   APG radio group pattern: repos/aria-practices/content/patterns/radio/
//
// Tailwind v4 dark mode here is class-based: `.dark` on <html>
// (@custom-variant dark (&:is(.dark *)) in app/styles/input.css). The boot
// script returned in the docs site.js toggles that class; "system" leaves the
// class off and lets prefers-color-scheme drive it via CSS.

export type ThemeChoice = "system" | "light" | "dark"

// Fixed, ordered set — the three real states. Each carries its icon + label.
export const THEME_OPTIONS: ThemeChoice[] = ["system", "light", "dark"]

const groupBase =
  "inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs " +
  "aria-disabled:pointer-events-none aria-disabled:opacity-50"

// The visible chip for each option. The real <input type="radio"> is a
// peer sibling rendered visually-hidden but still focusable; its :checked /
// :focus-visible state styles the label via the peer-* variants. This keeps
// keyboard + AT behaviour native while letting us draw a segmented control.
const itemBase =
  "relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors " +
  "peer-hover:bg-background/60 peer-hover:text-foreground " +
  "peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs " +
  "peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 " +
  "peer-disabled:cursor-not-allowed peer-disabled:opacity-50 " +
  "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"

const inputBase =
  "peer sr-only"

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

const ICONS: Record<ThemeChoice, Child> = {
  system: (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="20" height="14" x="2" y="3" rx="2" />
      <line x1="8" x2="16" y1="21" y2="21" />
      <line x1="12" x2="12" y1="17" y2="21" />
    </svg>
  ),
  light: (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="4" />
      <path d="M12 2v2" />
      <path d="M12 20v2" />
      <path d="m4.93 4.93 1.41 1.41" />
      <path d="m17.66 17.66 1.41 1.41" />
      <path d="M2 12h2" />
      <path d="M20 12h2" />
      <path d="m6.34 17.66-1.41 1.41" />
      <path d="m19.07 4.93-1.41 1.41" />
    </svg>
  ),
  dark: (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
    </svg>
  ),
}

const LABELS: Record<ThemeChoice, string> = {
  system: "System",
  light: "Light",
  dark: "Dark",
}

type ThemeToggleProps = {
  // The server-resolved current choice (from the cookie, or "system" when no
  // cookie is set). Drives which radio renders checked so there's no flash.
  value?: ThemeChoice
  // The radio group `name` + the cookie key the boot script reads/writes.
  // Defaults to "theme".
  name?: string
  // Id prefix for the inputs/labels (so multiple toggles can coexist).
  id?: string
  disabled?: boolean
  // Accessible name for the whole group (role=radiogroup). Defaults to
  // "Colour theme".
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  class?: ClassValue

  // htmx — fire on change to persist the choice server-side as well as in the
  // cookie (e.g. write it to the user's profile). The boot script already
  // applies the visual theme; this is purely for server persistence.
  "hx-get"?: string
  "hx-post"?: string
  "hx-put"?: string
  "hx-patch"?: string
  "hx-target"?: string
  "hx-swap"?: string
  "hx-trigger"?: string
  "hx-vals"?: string
}

export function ThemeToggle(props: ThemeToggleProps) {
  const {
    value = "system",
    name = "theme",
    id = "theme-toggle",
    disabled,
    ariaLabel = "Colour theme",
    ariaLabelledby,
    ariaDescribedby,
    class: className,
    ...rest
  } = props

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabelledby ? undefined : ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      aria-disabled={disabled ? "true" : undefined}
      data-slot="theme-toggle"
      data-name={name}
      data-value={value}
      class={themeToggleClasses({ class: className })}
      {...rest}
    >
      {THEME_OPTIONS.map((choice) => {
        const inputId = `${id}-${choice}`
        return (
          <span class="relative inline-flex">
            <input
              type="radio"
              id={inputId}
              name={name}
              value={choice}
              checked={choice === value}
              disabled={disabled}
              class={inputBase}
              data-slot="theme-toggle-item"
            />
            <label for={inputId} class={itemBase} data-slot="theme-toggle-label" title={LABELS[choice]}>
              {ICONS[choice]}
              <span class="sr-only">{LABELS[choice]}</span>
            </label>
          </span>
        )
      })}
    </div>
  )
}

1. Save the file

Copy theme-toggle.html into templates/components/.

2. Use it

templates/components/theme-toggle.html
{% from "components/theme-toggle.html" import theme_toggle %}

{{ theme_toggle(value=request.cookies.get("theme", "system")) }}
View source
templates/components/theme-toggle.html
{# Theme Toggle macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/theme-toggle.tsx EXACTLY.

   Light / dark / system colour-scheme switcher. The "system" default honours
   prefers-color-scheme; an explicit choice is persisted in a cookie so the
   server can re-render the right theme with no flash. The three states are a
   native radio group (one <input type="radio"> per choice, grouped by name).

   Built on web standards (NOT copied from these sources, just modelled on):
     - prefers-color-scheme media feature (the "system" default).
     - color-scheme property (native controls follow the theme).
     - cookie persistence + pre-paint boot script, adapted from web.dev:
         repos/web.dev/.../patterns/theming/theme-switch
         repos/web.dev/.../patterns/theming/color-schemes
   APG radio group: repos/aria-practices/content/patterns/radio/

   Usage:
     {% from "components/theme-toggle.html" import theme_toggle %}
     {{ theme_toggle(value=request.cookies.get("theme", "system")) }}

   The boot script (returned in the docs site.js) reads/writes the cookie,
   toggles `.dark` on <html>, and reflects the choice onto the radios. #}

{% macro theme_toggle(
    value="system",
    name="theme",
    id="theme-toggle",
    disabled=false,
    aria_label="Colour theme",
    aria_labelledby=none,
    aria_describedby=none,
    extra_class="",
    **attrs
) %}
{%- set group_base -%}
inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs aria-disabled:pointer-events-none aria-disabled:opacity-50
{%- endset -%}
{%- set item_base -%}
relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0
{%- endset -%}
{%- set icons = {
    "system": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>',
    "light": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>',
    "dark": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>'
} -%}
{%- set labels = {"system": "System", "light": "Light", "dark": "Dark"} -%}
<div role="radiogroup"
     {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% else %} aria-label="{{ aria_label }}"{% endif %}
     {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
     {%- if disabled %} aria-disabled="true"{% endif %}
     data-slot="theme-toggle"
     data-name="{{ name }}"
     data-value="{{ value }}"
     class="{{ group_base }} {{ extra_class }}"
     {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor %}>
{%- for choice in ["system", "light", "dark"] %}
  <span class="relative inline-flex">
    <input type="radio"
           id="{{ id }}-{{ choice }}"
           name="{{ name }}"
           value="{{ choice }}"
           {%- if choice == value %} checked{% endif %}
           {%- if disabled %} disabled{% endif %}
           class="peer sr-only"
           data-slot="theme-toggle-item">
    <label for="{{ id }}-{{ choice }}" class="{{ item_base }}" data-slot="theme-toggle-label" title="{{ labels[choice] }}">
      {{ icons[choice]|safe }}
      <span class="sr-only">{{ labels[choice] }}</span>
    </label>
  </span>
{%- endfor %}
</div>
{% endmacro %}

1. Save the file

Add theme-toggle.tmpl alongside your templates.

2. Use it

components/theme-toggle.tmpl
tpl.ExecuteTemplate(w, "theme-toggle", map[string]any{
    "Value": cookieValue(r, "theme"), // "" → renders "system"
})
View source
components/theme-toggle.tmpl
{{/*
  Theme Toggle template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/theme-toggle.tsx EXACTLY.

  Light / dark / system colour-scheme switcher. The "system" default honours
  prefers-color-scheme; an explicit choice is persisted in a cookie so the
  server can re-render the right theme with no flash. The three states are a
  native radio group (one <input type="radio"> per choice, grouped by name).

  Built on web standards (modelled on, NOT copied from):
    - prefers-color-scheme media feature (the "system" default).
    - color-scheme property (native controls follow the theme).
    - cookie persistence + pre-paint boot script, adapted from web.dev:
        repos/web.dev/.../patterns/theming/theme-switch
        repos/web.dev/.../patterns/theming/color-schemes
  APG radio group: repos/aria-practices/content/patterns/radio/

  Usage:

      type ThemeToggleArgs struct {
          Value          string // system | light | dark (from the cookie)
          Name           string // radio group name + cookie key (default "theme")
          ID             string // id prefix (default "theme-toggle")
          Disabled       bool
          AriaLabel      string // default "Colour theme"
          AriaLabelledby string
          AriaDescribedby string
          Attrs          map[string]string // hx-* etc.
      }

      tpl.ExecuteTemplate(w, "theme-toggle", ThemeToggleArgs{
          Value: r.CookieValue("theme"), // "" → renders "system"
      })

  Pair with the boot script (see the docs site.js block) so the cookie is
  read/written and `.dark` is toggled on <html> with no flash.
*/}}

{{define "theme-toggle"}}
{{- $value := or .Value "system" -}}
{{- $name := or .Name "theme" -}}
{{- $id := or .ID "theme-toggle" -}}
{{- $ariaLabel := or .AriaLabel "Colour theme" -}}
{{- $groupBase := "inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs aria-disabled:pointer-events-none aria-disabled:opacity-50" -}}
{{- $itemBase := "relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0" -}}
{{- $icons := dict
    "system" (htmlSafe `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>`)
    "light" (htmlSafe `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>`)
    "dark" (htmlSafe `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>`) -}}
{{- $labels := dict "system" "System" "light" "Light" "dark" "Dark" -}}
<div role="radiogroup"
     {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{else}} aria-label="{{$ariaLabel}}"{{end}}
     {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
     {{- if .Disabled}} aria-disabled="true"{{end}}
     data-slot="theme-toggle"
     data-name="{{$name}}"
     data-value="{{$value}}"
     class="{{$groupBase}}"
     {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end}}>
{{- range $choice := list "system" "light" "dark"}}
  <span class="relative inline-flex">
    <input type="radio"
           id="{{$id}}-{{$choice}}"
           name="{{$name}}"
           value="{{$choice}}"
           {{- if eq $choice $value}} checked{{end}}
           {{- if $.Disabled}} disabled{{end}}
           class="peer sr-only"
           data-slot="theme-toggle-item">
    <label for="{{$id}}-{{$choice}}" class="{{$itemBase}}" data-slot="theme-toggle-label" title="{{index $labels $choice}}">
      {{index $icons $choice}}
      <span class="sr-only">{{index $labels $choice}}</span>
    </label>
  </span>
{{- end}}
</div>
{{end}}

{{/*
  Note: uses sprig helpers `dict`, `list`, and `htmlSafe`. Without sprig,
  hard-code the inline SVGs and the label/icon lookups directly in the range
  body instead of building the maps.
*/}}

1. Save the file

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

2. Use it

lib/my_app_web/components/theme_toggle.ex
alias ShadcnHtmx.Components.ThemeToggle

<ThemeToggle.theme_toggle value={@conn.cookies["theme"] || "system"} />
View source
lib/my_app_web/components/theme_toggle.ex
defmodule ShadcnHtmx.Components.ThemeToggle do
  @moduledoc """
  Theme Toggle — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/theme-toggle.tsx EXACTLY. A light / dark / system
  colour-scheme switcher. The "system" default honours prefers-color-scheme;
  an explicit choice is persisted in a cookie so the server can re-render the
  right theme with no flash. The three states are a native radio group (one
  `<input type="radio">` per choice, grouped by `name`).

  Built on web standards (modelled on, NOT copied from these sources):
    - prefers-color-scheme media feature (the "system" default).
    - color-scheme property (native controls follow the theme).
    - cookie persistence + a pre-paint boot script, adapted from web.dev:
        repos/web.dev/.../patterns/theming/theme-switch
        repos/web.dev/.../patterns/theming/color-schemes
  APG radio group: repos/aria-practices/content/patterns/radio/

  ## Examples

      <.theme_toggle value={@conn.cookies["theme"] || "system"} />

      # persist server-side too (cookie is set by the boot script):
      <.theme_toggle value={@theme} hx-post="/prefs/theme" hx-trigger="change" hx-swap="none" />

  Pair with the boot script (see the docs site.js block) so the cookie is
  read/written and `.dark` is toggled on <html> with no flash.
  """

  use Phoenix.Component

  @group_base "inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs " <>
                "aria-disabled:pointer-events-none aria-disabled:opacity-50"

  @item_base "relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors " <>
               "peer-hover:bg-background/60 peer-hover:text-foreground " <>
               "peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs " <>
               "peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 " <>
               "peer-disabled:cursor-not-allowed peer-disabled:opacity-50 " <>
               "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"

  @icons %{
    "system" =>
      ~s(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>),
    "light" =>
      ~s(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>),
    "dark" =>
      ~s(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>)
  }

  @labels %{"system" => "System", "light" => "Light", "dark" => "Dark"}

  attr :value, :string, default: "system", values: ~w(system light dark)
  attr :name, :string, default: "theme"
  attr :id, :string, default: "theme-toggle"
  attr :disabled, :boolean, default: false
  attr :aria_label, :string, default: "Colour theme"
  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
         aria-labelledby aria-describedby)

  def theme_toggle(assigns) do
    assigns =
      assigns
      |> assign(:group_base, @group_base)
      |> assign(:item_base, @item_base)
      |> assign(:options, ~w(system light dark))
      |> assign(:icons, @icons)
      |> assign(:labels, @labels)

    ~H"""
    <div
      role="radiogroup"
      aria-label={@aria_label}
      aria-disabled={if @disabled, do: "true", else: nil}
      data-slot="theme-toggle"
      data-name={@name}
      data-value={@value}
      class={[@group_base, @class]}
      {@rest}
    >
      <span :for={choice <- @options} class="relative inline-flex">
        <input
          type="radio"
          id={"#{@id}-#{choice}"}
          name={@name}
          value={choice}
          checked={choice == @value}
          disabled={@disabled}
          class="peer sr-only"
          data-slot="theme-toggle-item"
        />
        <label
          for={"#{@id}-#{choice}"}
          class={@item_base}
          data-slot="theme-toggle-label"
          title={@labels[choice]}
        >
          {Phoenix.HTML.raw(@icons[choice])}
          <span class="sr-only">{@labels[choice]}</span>
        </label>
      </span>
    </div>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/theme-toggle.html
<!-- Paste into <head> (boot script) + body (control). Relies only on
     theme tokens + class-based .dark on <html>. No build step needed. -->
<div role="radiogroup" aria-label="Colour theme"
     data-slot="theme-toggle" data-name="theme" data-value="system"
     class="inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 …">
  …three radios: system / light / dark…
</div>
View source
snippets/theme-toggle.html
<!--
  shadcn-htmx — raw HTML theme-toggle snippet.

  Mirrors registry/ui/theme-toggle.tsx. A light / dark / system colour-scheme
  switcher rendered as a native radio group (one <input type="radio"> per
  choice, grouped by name). Native arrow-key roving focus, single selection,
  and aria-checked come for free.

  Built on web standards (modelled on, NOT copied from):
    - prefers-color-scheme media feature — the "system" default:
        https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
    - color-scheme property — native controls + scrollbars follow the theme.
    - cookie persistence + a synchronous pre-paint boot script so there's no
      flash of the wrong colours, adapted from the web.dev theming patterns
      (theme-switch / color-schemes — those use localStorage; we use a cookie
      so a server can read it and render `.dark` up front).

  Requirements:
    1. Tailwind CSS v4 with class-based dark mode (`.dark` on <html>):
         @custom-variant dark (&:is(.dark *));
    2. The shadcn CSS variables (:root + .dark blocks) from
       app/styles/input.css.

  GROUP base:
    inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5
    text-muted-foreground shadow-xs
    aria-disabled:pointer-events-none aria-disabled:opacity-50
  ITEM base (the visible chip — the real radio is a visually-hidden peer):
    relative inline-flex size-7 cursor-pointer items-center justify-center
    rounded-[5px] outline-none transition-colors
    peer-hover:bg-background/60 peer-hover:text-foreground
    peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs
    peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50
    peer-disabled:cursor-not-allowed peer-disabled:opacity-50
    [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0

  NO-FLASH: put this snippet in <head>, BEFORE your stylesheet, so the cookie
  is read and `.dark` is applied before the first paint.
-->

<!--
  1. Pre-paint boot — runs synchronously in <head>. Reads the `theme` cookie
     ("system" | "light" | "dark"; default "system") and applies `.dark` to
     <html> so the page never flashes the wrong colours.
-->
<script>
  (function () {
    try {
      var m = document.cookie.match(/(?:^|;\s*)theme=(system|light|dark)/)
      var choice = m ? m[1] : 'system'
      var dark =
        choice === 'dark' ||
        (choice === 'system' &&
          window.matchMedia('(prefers-color-scheme: dark)').matches)
      document.documentElement.classList.toggle('dark', dark)
      document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
    } catch (e) {}
  })()
</script>

<!-- 2. The control. data-value is the server-resolved current choice. -->
<div role="radiogroup" aria-label="Colour theme"
     data-slot="theme-toggle" data-name="theme" data-value="system"
     class="inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs aria-disabled:pointer-events-none aria-disabled:opacity-50">

  <span class="relative inline-flex">
    <input type="radio" id="theme-toggle-system" name="theme" value="system" checked
           class="peer sr-only" data-slot="theme-toggle-item">
    <label for="theme-toggle-system" data-slot="theme-toggle-label" title="System"
           class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>
      <span class="sr-only">System</span>
    </label>
  </span>

  <span class="relative inline-flex">
    <input type="radio" id="theme-toggle-light" name="theme" value="light"
           class="peer sr-only" data-slot="theme-toggle-item">
    <label for="theme-toggle-light" data-slot="theme-toggle-label" title="Light"
           class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
      <span class="sr-only">Light</span>
    </label>
  </span>

  <span class="relative inline-flex">
    <input type="radio" id="theme-toggle-dark" name="theme" value="dark"
           class="peer sr-only" data-slot="theme-toggle-item">
    <label for="theme-toggle-dark" data-slot="theme-toggle-label" title="Dark"
           class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
      <span class="sr-only">Dark</span>
    </label>
  </span>
</div>

<!--
  3. Behaviour — on change, write the cookie and re-apply the theme. Keeps a
     "system" listener so the page tracks the OS when no explicit override is
     chosen. (In the docs site this lives in a shared site.js; inlined here so
     the snippet is self-contained.)
-->
<script>
  (function () {
    var root = document.querySelector('[data-slot="theme-toggle"]')
    if (!root) return
    var media = window.matchMedia('(prefers-color-scheme: dark)')

    function apply(choice) {
      var dark = choice === 'dark' || (choice === 'system' && media.matches)
      document.documentElement.classList.toggle('dark', dark)
      document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
    }
    function setCookie(choice) {
      document.cookie =
        'theme=' + choice + ';path=/;max-age=31536000;samesite=lax'
    }

    root.addEventListener('change', function (e) {
      var t = e.target
      if (!t || t.getAttribute('data-slot') !== 'theme-toggle-item') return
      root.setAttribute('data-value', t.value)
      setCookie(t.value)
      apply(t.value)
    })

    media.addEventListener('change', function () {
      if (root.getAttribute('data-value') === 'system') apply('system')
    })
  })()
</script>

Examples

The toggle below is wired to this page's boot script — pick an option and watch the docs theme change. "System" follows your OS setting live.

Light / dark / system

Three real states modelled as a native radio group. Arrow keys move between options; only one is selected at a time.

A two-state toggle can't express "follow the system" — so we use a radio group of three. system is the default and leaves the theme to the prefers-color-scheme media feature; picking light or dark is an explicit override. Because they're native radios sharing one name, the browser gives us roving arrow-key focus, single selection, and aria-checked for free.

<ThemeToggle value={cookies.theme ?? "system"} />
{{ theme_toggle(value=request.cookies.get("theme", "system")) }}
{{template "theme-toggle" (dict "Value" (cookie .Request "theme"))}}
<ThemeToggle.theme_toggle value={@conn.cookies["theme"] || "system"} />
<div class="flex justify-center">
  <div role="radiogroup" aria-label="Colour theme" data-slot="theme-toggle" data-name="theme" data-value="system" class="inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs aria-disabled:pointer-events-none aria-disabled:opacity-50">
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-system" name="theme" value="system" checked="" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-system" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="System">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <rect width="20" height="14" x="2" y="3" rx="2">
          </rect>
          <line x1="8" x2="16" y1="21" y2="21">
          </line>
          <line x1="12" x2="12" y1="17" y2="21">
          </line>
        </svg>
        <span class="sr-only">System</span>
      </label>
    </span>
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-light" name="theme" value="light" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-light" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="Light">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="4">
          </circle>
          <path d="M12 2v2">
          </path>
          <path d="M12 20v2">
          </path>
          <path d="m4.93 4.93 1.41 1.41">
          </path>
          <path d="m17.66 17.66 1.41 1.41">
          </path>
          <path d="M2 12h2">
          </path>
          <path d="M20 12h2">
          </path>
          <path d="m6.34 17.66-1.41 1.41">
          </path>
          <path d="m19.07 4.93-1.41 1.41">
          </path>
        </svg>
        <span class="sr-only">Light</span>
      </label>
    </span>
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-dark" name="theme" value="dark" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-dark" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="Dark">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z">
          </path>
        </svg>
        <span class="sr-only">Dark</span>
      </label>
    </span>
  </div>
</div>

The server reads the cookie and renders the right radio checked + the .dark class up front. A synchronous pre-paint script applies it before first paint.

htmx / server-rendered apps can't read localStorage on the server, so the web.dev theme-switch trick (which uses it) would flash the wrong colours on first paint. Storing the choice in a theme cookie fixes that: the server sends back the correct .dark class and the matching checked radio, and a tiny inline boot script in <head> re-confirms it before the body renders. The script also sets color-scheme so native scrollbars and form controls match.

Rendered with value="light" — the Light radio is checked server-side.

// Boot script (in your <head>, before the stylesheet):
//   var m = document.cookie.match(/theme=(system|light|dark)/)
//   var choice = m ? m[1] : "system"
//   var dark = choice === "dark" ||
//     (choice === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
//   document.documentElement.classList.toggle("dark", dark)

<ThemeToggle value={cookies.theme ?? "system"} />
{# server reads the cookie → no flash #}
{{ theme_toggle(value=request.cookies.get("theme", "system")) }}
{{template "theme-toggle" (dict "Value" (cookie .Request "theme"))}}
<ThemeToggle.theme_toggle value={@conn.cookies["theme"] || "system"} />
<div class="flex flex-col items-center gap-3">
  <div role="radiogroup" aria-label="Colour theme" data-slot="theme-toggle" data-name="theme" data-value="light" class="inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs aria-disabled:pointer-events-none aria-disabled:opacity-50">
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-system" name="theme" value="system" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-system" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="System">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <rect width="20" height="14" x="2" y="3" rx="2">
          </rect>
          <line x1="8" x2="16" y1="21" y2="21">
          </line>
          <line x1="12" x2="12" y1="17" y2="21">
          </line>
        </svg>
        <span class="sr-only">System</span>
      </label>
    </span>
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-light" name="theme" value="light" checked="" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-light" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="Light">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="4">
          </circle>
          <path d="M12 2v2">
          </path>
          <path d="M12 20v2">
          </path>
          <path d="m4.93 4.93 1.41 1.41">
          </path>
          <path d="m17.66 17.66 1.41 1.41">
          </path>
          <path d="M2 12h2">
          </path>
          <path d="M20 12h2">
          </path>
          <path d="m6.34 17.66-1.41 1.41">
          </path>
          <path d="m19.07 4.93-1.41 1.41">
          </path>
        </svg>
        <span class="sr-only">Light</span>
      </label>
    </span>
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-dark" name="theme" value="dark" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-dark" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="Dark">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z">
          </path>
        </svg>
        <span class="sr-only">Dark</span>
      </label>
    </span>
  </div>
  <p class="text-xs text-muted-foreground">
    Rendered with
    <code class="rounded bg-muted px-1 py-0.5">value=&quot;light&quot;</code>
    — the Light radio is checked server-side.
  </p>
</div>

htmx — persist server-side

The boot script already writes the cookie and flips the theme. Add hx-post to also persist the choice to the user's profile on the server.

The cookie + boot script handle the visual switch with zero round-trips. When you also want the preference stored against a logged-in user, hang htmx attributes off the group: hx-post the new value on hx-trigger="change" with hx-swap="none" — the server reads the radio value and saves it. The change event bubbles from the selected radio to the group, so a single set of attributes on the root covers all three options.

No preference saved yet.
<ThemeToggle
  value={cookies.theme ?? "system"}
  hx-post="/prefs/theme"
  hx-trigger="change"
  hx-swap="none"
/>
{{ theme_toggle(value=request.cookies.get("theme", "system"),
    hx_post="/prefs/theme", hx_trigger="change", hx_swap="none") }}
{{template "theme-toggle" (dict
  "Value" (cookie .Request "theme")
  "Attrs" (dict "hx-post" "/prefs/theme" "hx-trigger" "change" "hx-swap" "none")
)}}
<ThemeToggle.theme_toggle value={@theme}
  hx-post="/prefs/theme" hx-trigger="change" hx-swap="none" />
<div class="flex flex-col items-center gap-3">
  <div role="radiogroup" aria-label="Colour theme" data-slot="theme-toggle" data-name="theme" data-value="system" class="inline-flex items-center gap-0.5 rounded-md border bg-muted p-0.5 text-muted-foreground shadow-xs aria-disabled:pointer-events-none aria-disabled:opacity-50" hx-post="/docs/theme-toggle/save" hx-trigger="change" hx-target="#theme-save-out" hx-swap="innerHTML">
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-htmx-system" name="theme" value="system" checked="" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-htmx-system" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="System">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <rect width="20" height="14" x="2" y="3" rx="2">
          </rect>
          <line x1="8" x2="16" y1="21" y2="21">
          </line>
          <line x1="12" x2="12" y1="17" y2="21">
          </line>
        </svg>
        <span class="sr-only">System</span>
      </label>
    </span>
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-htmx-light" name="theme" value="light" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-htmx-light" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="Light">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="4">
          </circle>
          <path d="M12 2v2">
          </path>
          <path d="M12 20v2">
          </path>
          <path d="m4.93 4.93 1.41 1.41">
          </path>
          <path d="m17.66 17.66 1.41 1.41">
          </path>
          <path d="M2 12h2">
          </path>
          <path d="M20 12h2">
          </path>
          <path d="m6.34 17.66-1.41 1.41">
          </path>
          <path d="m19.07 4.93-1.41 1.41">
          </path>
        </svg>
        <span class="sr-only">Light</span>
      </label>
    </span>
    <span class="relative inline-flex">
      <input type="radio" id="theme-toggle-htmx-dark" name="theme" value="dark" class="peer sr-only" data-slot="theme-toggle-item"/>
      <label for="theme-toggle-htmx-dark" class="relative inline-flex size-7 cursor-pointer items-center justify-center rounded-[5px] outline-none transition-colors peer-hover:bg-background/60 peer-hover:text-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-xs peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0" data-slot="theme-toggle-label" title="Dark">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z">
          </path>
        </svg>
        <span class="sr-only">Dark</span>
      </label>
    </span>
  </div>
  <span id="theme-save-out" class="text-sm text-muted-foreground" aria-live="polite">No preference saved yet.</span>
</div>

API Reference

<ThemeToggle>

The change event bubbles from the selected radio to the group, so hx-* on the root covers all three options.

PropTypeDefaultDescription
value"system"|"light"|"dark""system"
Server-resolved current choice (read from the `theme` cookie). Drives which radio renders checked + the .dark class, so there is no flash on first paint.MDNprefers-color-scheme
namestring"theme"
Radio group name and the cookie key the boot script reads/writes. All three radios share it so the browser groups them.
idstring"theme-toggle"
Id prefix for the inputs and their labels, so multiple toggles can coexist on one page.
disabledbooleanfalse
Disable the whole group — sets aria-disabled on the root and disabled on every radio.
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