ThemeToggle
SM · 950B gzip budgetA light/dark/system toggle that persists its choice.
- Category
- Accessibility & preferences
- Budget
- sm, single-purpose control
- External runtime
- React / React DOM only
axe fixtureSSR rendersource scanbundle budget
Preview
Live fixtureTry the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.
Install
npx shadcn@latest add https://gear5-ui.vercel.app/r/theme-toggle.jsonCopies the source into your project. Pulls in 2 shared registry items: cn, use-stored-value.
New here? Set up Tailwind and import aliases first →Source & props
Edit on GitHub ↗Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.
"use client";
import { useEffect } from "react";
import { cn } from "../lib/cn";
import { useStoredValue } from "../lib/use-stored-value";
export type Theme = "light" | "dark" | "system";
const STORAGE_KEY = "gear5-ui:theme";
function apply(theme: Theme) {
const root = document.documentElement;
const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark);
// Set here, not in CSS: a `color-scheme` declaration on both `:root` and
// `.dark` gets rewritten by some CSS build tools into a `light-dark()`
// function that follows the OS preference regardless of this class. An
// inline style bypasses that rewrite and still gets native form controls,
// scrollbars, and text-selection colours right for the active theme.
root.style.colorScheme = dark ? "dark" : "light";
}
export interface ThemeToggleProps {
className?: string;
/**
* What to use before the reader has chosen. Defaults to "system"; a product
* whose design is dark-first should say so here rather than inheriting an
* OS preference that contradicts it.
*/
defaultTheme?: Theme;
}
/**
* A light/dark/system toggle. Its stored choice is read via
* `useSyncExternalStore` (see `useStoredValue`), so the server and the
* client's first render agree — this is why the library ships no inline
* `<script>` snippet of its own for FOUC prevention; consumers who need
* zero-flash dark mode add the one-line inline script Next.js's own docs
* describe, reading the same `gear5-ui:theme` key.
*/
export function ThemeToggle({ className, defaultTheme = "system" }: ThemeToggleProps) {
const [stored, setStored] = useStoredValue(STORAGE_KEY, defaultTheme);
const theme = (stored as Theme | null) ?? defaultTheme;
// Applying the theme to the document is a real side effect on an external
// system (the DOM outside this component) — the case useEffect exists for,
// distinct from mirroring a value into this component's own state.
useEffect(() => apply(theme), [theme]);
const options: Array<{ value: Theme; label: string }> = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
return (
<div role="radiogroup" aria-label="Theme" className={cn("inline-flex rounded-md border border-neutral-300 p-0.5 dark:border-neutral-700", className)}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={theme === option.value}
onClick={() => setStored(option.value)}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600",
theme === option.value ? "bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-900" : "text-neutral-600 dark:text-neutral-400",
)}
>
{option.label}
</button>
))}
</div>
);
}
What CI checks
Quality contract- Bundled, minified and gzipped against its tier budget
- Audited by axe in the state previewed above
- Rendered through react-dom/server with no browser globals
- Scanned for network calls, dangerous sinks, and unguarded animation