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.json2. Use it
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
/** @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
{% from "components/theme-toggle.html" import theme_toggle %}
{{ theme_toggle(value=request.cookies.get("theme", "system")) }}View source
{# 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
tpl.ExecuteTemplate(w, "theme-toggle", map[string]any{
"Value": cookieValue(r, "theme"), // "" → renders "system"
})View source
{{/*
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
alias ShadcnHtmx.Components.ThemeToggle
<ThemeToggle.theme_toggle value={@conn.cookies["theme"] || "system"} />View source
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
<!-- 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
<!--
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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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>No-flash cookie boot
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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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="light"</code>
— the Light radio is checked server-side.
</p>
</div>Further reading
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.
<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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_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>Further reading
API Reference
<ThemeToggle>
The change event bubbles from the selected radio to the group, so hx-* on the root covers all three options.
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
name | string | "theme" | Radio group name and the cookie key the boot script reads/writes. All three radios share it so the browser groups them. |
id | string | "theme-toggle" | Id prefix for the inputs and their labels, so multiple toggles can coexist on one page. |
disabled | boolean | false | Disable the whole group — sets aria-disabled on the root and disabled on every radio. |
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 |