Theme Toggle
System, light and dark as one segmented control, plus the round light and dark button ward.so uses; it can apply and remember the choice.
"use client";
import { useState } from "react";
import { Moon, Sun } from "lucide-react";
import { applyTheme, saveTheme, themes, type Theme } from "./theme";
import "./styles/theme-toggle.css";
export { applyTheme, saveTheme, type Theme } from "./theme";
const label = (theme: Theme) => theme[0].toUpperCase() + theme.slice(1);
const classes = (...names: (string | undefined)[]) => names.filter(Boolean).join(" ");
/** System, light and dark as one segmented control. With apply, a choice also updates <html> and is remembered. */
export default function ThemeToggle({ variant = "System", value, onChange, apply = false, storageKey = "ward-theme", className }: { variant?: string; value?: Theme; onChange?: (theme: Theme) => void; apply?: boolean; storageKey?: string; className?: string }) {
const [selected, setSelected] = useState<Theme>(themes.find(theme => theme === variant.toLowerCase()) ?? "system");
const current = value ?? selected;
function choose(theme: Theme) {
if (value === undefined) setSelected(theme);
if (apply) { applyTheme(theme); saveTheme(theme, storageKey); }
onChange?.(theme);
}
return <div className={classes("ward-theme-toggle", className)} role="group" aria-label="Appearance">{themes.map(theme =>
<button key={theme} type="button" aria-pressed={current === theme} onClick={() => choose(theme)}>{label(theme)}</button>)}</div>;
}
/** ward.so's rail control: one round button that flips light and dark. */
export function ThemeIconToggle({ className, storageKey = "ward-theme" }: { className?: string; storageKey?: string }) {
return <button type="button" className={classes("ward-theme-icon", className)} aria-label="Toggle light and dark mode" onClick={() => {
const root = document.documentElement;
const next: Theme = root.dataset.theme === "dark" || root.classList.contains("dark") ? "light" : "dark";
applyTheme(next); saveTheme(next, storageKey);
}}><Moon className="ward-theme-icon-light" size={15} aria-hidden="true" /><Sun className="ward-theme-icon-dark" size={15} aria-hidden="true" /></button>;
}export type Theme = "system" | "light" | "dark";
export const themes: readonly Theme[] = ["system", "light", "dark"];
export function resolveTheme(theme: Theme, prefersDark: boolean): "light" | "dark" {
return theme === "system" ? (prefersDark ? "dark" : "light") : theme;
}
/** Puts a theme on <html> in both forms Ward's CSS reads: data-theme (ward.so) and the .dark class (shadcn). */
export function applyTheme(theme: Theme, root: HTMLElement = document.documentElement) {
const resolved = resolveTheme(theme, matchMedia("(prefers-color-scheme: dark)").matches);
root.dataset.theme = resolved;
root.classList.toggle("dark", resolved === "dark");
root.style.colorScheme = resolved;
return resolved;
}
export function saveTheme(theme: Theme, storageKey = "ward-theme") {
try { localStorage.setItem(storageKey, theme); } catch { /* Private mode: the page still switches. */ }
}.ward-theme-toggle { display: inline-flex; gap: 4px; }
.ward-theme-toggle button { height: 28px; padding: 0 12px; border: 0; border-radius: 999px; background: var(--secondary); color: var(--muted-foreground); font: inherit; font-size: 13px; cursor: pointer; transition: background 120ms cubic-bezier(.2, 0, 0, 1), color 120ms cubic-bezier(.2, 0, 0, 1); }
.ward-theme-toggle button:hover { color: var(--foreground); }
.ward-theme-toggle button[aria-pressed="true"] { background: var(--primary); color: var(--primary-foreground); }
.ward-theme-icon { display: inline-grid; flex: none; width: 28px; height: 28px; place-items: center; padding: 0; border: 0; border-radius: 999px; background: var(--secondary); color: var(--foreground); cursor: pointer; transition: background 180ms cubic-bezier(.2, 0, 0, 1); }
.ward-theme-icon:hover { background: var(--accent); }
.ward-theme-toggle button:focus-visible, .ward-theme-icon:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.ward-theme-icon-dark, :is(.dark, [data-theme="dark"]) .ward-theme-icon-light { display: none; }
:is(.dark, [data-theme="dark"]) .ward-theme-icon-dark { display: block; }
@media (prefers-reduced-motion: reduce) { .ward-theme-toggle button, .ward-theme-icon { transition: none; } }Installation
pnpm dlx shadcn@latest add @ward/theme-togglenpx shadcn@latest add @ward/theme-toggleyarn dlx shadcn@latest add @ward/theme-togglebunx --bun shadcn@latest add @ward/theme-toggleAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/theme-toggle.json
Install the dependencies.
npm install lucide-react@1.47.0Copy each file from the Code tab into:
components/ward/ThemeToggle.tsxcomponents/ward/theme.tscomponents/ward/styles/theme-toggle.css
Update the import paths to match your project.
Usage
import ThemeToggle, { ThemeIconToggle } from "@/components/ward/ThemeToggle";<ThemeToggle apply />
<ThemeIconToggle />Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "System" | "Light" | "Dark" | "System" | Appearance selected when the toggle first renders without a value. |
value | "system" | "light" | "dark" | - | Selected appearance when the parent controls the toggle. |
onChange | (theme: "system" | "light" | "dark") => void | - | Called with the chosen appearance. |
apply | boolean | false | Also set data-theme and the .dark class on <html> and remember the choice. |
storageKey | string | "ward-theme" | localStorage key used when apply is on; ThemeIconToggle takes it too. |
How to build
Give people a predictable choice between system, light and dark appearance.
Use the original Ward control below when you need a compact appearance setting. Persist the chosen mode in your application, then apply it to the document before hydration to avoid a flash.
Ward · Version 2.0.0 · Registry manifest