A light/dark toggle whose chip reads the real resolved --background/--foreground values at mount and on every theme change and paints itself in their negative, so it previews almost exactly what the page will look like the instant you click it.
npx shadcn add https://design.helpmarq.com /r/toggle-theme-ascii.jsonregistry/core/toggle-theme-ascii/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// ThemeToggleAscii — a real theme toggle whose swatch previews the theme
// it is *about* to switch to, honestly: at mount and on every theme change
// it reads the actual resolved --background/--foreground values via
// getComputedStyle and paints its own chip in their NEGATIVE — chip
// background = the current foreground token, chip ink = the current
// background token — so the swatch always shows, in real token colors, very
// nearly what the page will look like the instant you click it. That is a
// mechanic only a theme control can have: it would mean nothing on a switch
// that doesn't change the palette it's drawn in. A small ascii sun/moon
// glyph rides inside the chip and cross-fades between the two forms.
// ---------------------------------------------------------------------------
const SUN = [" \\|/ ", "-(O)-", " /|\\ "];
const MOON = [" .-)", " ( ", " `-)"];
function readToken(name: string): string {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || (name === "--background" ? "#ffffff" : "#000000");
}
export interface ThemeToggleAsciiProps {
/** controlled dark state; omit for uncontrolled (reads/writes <html class="dark">) */
dark?: boolean;
defaultDark?: boolean;
onDarkChange?: (dark: boolean) => void;
/** whether clicking mutates document.documentElement's "dark" class + localStorage */
syncDocument?: boolean;
/** localStorage key used when syncDocument is true */
storageKey?: string;
className?: string;
}
export function ThemeToggleAscii({
dark,
defaultDark = false,
onDarkChange,
syncDocument = true,
storageKey = "ns-ui-theme",
className = "",
}: ThemeToggleAsciiProps) {
const isControlled = dark !== undefined;
const [internalDark, setInternalDark] = useState(defaultDark);
const [mounted, setMounted] = useState(false);
const isDark = isControlled ? (dark as boolean) : internalDark;
const chipRef = useRef<HTMLSpanElement>(null);
// paint the negative-preview chip from real resolved token values, at
// mount and every time <html>'s class list changes (our own click, or any
// other control on the page toggling the same theme).
useEffect(() => {
const paint = () => {
const chip = chipRef.current;
if (!chip) return;
const bg = readToken("--background");
const fg = readToken("--foreground");
// negative: chip paper = foreground token, chip ink = background token
chip.style.backgroundColor = fg;
chip.style.color = bg;
};
paint();
setMounted(true);
const mo = new MutationObserver(paint);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
}, []);
// sync from the initial real document state on mount (uncontrolled only)
useEffect(() => {
if (isControlled) return;
setInternalDark(document.documentElement.classList.contains("dark"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggle = () => {
const next = !isDark;
if (!isControlled) setInternalDark(next);
onDarkChange?.(next);
if (syncDocument) {
document.documentElement.classList.toggle("dark", next);
try {
localStorage.setItem(storageKey, next ? "dark" : "light");
} catch {
// storage unavailable (private mode) — the toggle still works for this tab
}
}
};
const reduced =
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return (
<button
type="button"
onClick={toggle}
aria-pressed={mounted ? isDark : undefined}
aria-label={mounted ? (isDark ? "Switch to light theme" : "Switch to dark theme") : "Toggle theme"}
suppressHydrationWarning
className={`group inline-flex items-center gap-2 rounded-sm border border-border px-2 py-1.5 font-mono text-xs text-foreground transition-colors hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${className}`}
>
<span
ref={chipRef}
aria-hidden
className="relative grid size-8 place-items-center overflow-hidden rounded-sm border border-border transition-transform duration-200 group-hover:scale-105 motion-reduce:transition-none"
>
<pre
className={`pointer-events-none absolute inset-0 grid place-items-center whitespace-pre text-[9px] leading-tight transition-opacity duration-200 motion-reduce:transition-none ${
isDark ? "opacity-0" : "opacity-100"
}`}
style={reduced ? { transition: "none" } : undefined}
>
{SUN.join("\n")}
</pre>
<pre
className={`pointer-events-none absolute inset-0 grid place-items-center whitespace-pre text-[9px] leading-tight transition-opacity duration-200 motion-reduce:transition-none ${
isDark ? "opacity-100" : "opacity-0"
}`}
style={reduced ? { transition: "none" } : undefined}
>
{MOON.join("\n")}
</pre>
</span>
<span className="uppercase tracking-[0.15em] text-muted group-hover:text-foreground">
{mounted ? (isDark ? "dark" : "light") : "theme"}
</span>
</button>
);
}
Build <ThemeToggleAscii dark? defaultDark? onDarkChange? syncDocument? storageKey? className?> — same controlled/uncontrolled contract as the repo's other toggles, defaulting syncDocument to true (clicking toggles document.documentElement's "dark" class and writes storageKey, default "ns-ui-theme", to localStorage; wrapped in try/catch since localStorage throws in locked-down contexts) and storageKey overridable so a consumer with a different theme key isn't locked to this one. STRUCTURE: a single real <button aria-pressed aria-label> containing an aria-hidden 8-unit chip and a visible uppercase 'light'/'dark' text label (aria-hidden false — it's part of the accessible picture alongside aria-label, but aria-label is authoritative and states the action: 'Switch to light theme'/'Switch to dark theme'). THE MECHANIC — the one only a theme control can have: on mount, and on every mutation of <html>'s class attribute (a MutationObserver, not just the toggle's own click — so it stays correct if some other control on the page changes the theme too), read the actual resolved custom-property values via getComputedStyle(document.documentElement).getPropertyValue('--background'/'--foreground') and paint the chip in their NEGATIVE: chip background = the current foreground token's resolved value, chip ink (text color) = the current background token's resolved value. Because foreground and background swap between the two themes, the chip is always showing, in real token colors read at runtime (never a hardcoded hex, satisfying the token rule even inside this imperative color-setting code), very nearly what the whole page will look like immediately after the next click — a live preview of the target state, not a static icon. Test for whether a mechanic belongs here: it would mean nothing on a control that doesn't change the palette it's drawn in. GLYPH: two absolutely-stacked <pre aria-hidden> blocks inside the chip — a small ascii sun (rays radiating from a parenthesized O) and a small ascii moon (a parenthesis-drawn crescent) — cross-fading via opacity over 200ms keyed off the current dark state, with prefers-reduced-motion dropping the transition to an instant swap. ACCESSIBILITY: a real <button>, aria-pressed kept in sync, an aria-label that names the action rather than merely describing current state, hover (chip scale-105 plus border brightening) and focus-visible (outline-2 outline-offset-2 outline-accent, with no outline-none on the same element — Tailwind v4 latches --tw-outline-style to none permanently if both classes are present, and the ring silently never paints even though the classes look correct) states that are visibly distinct from rest. `mounted` gates the label/aria-pressed text (client-decided theme, unknowable during SSR) exactly like the repo's existing app-level ThemeToggle, and suppressHydrationWarning covers the one-frame mismatch between the server's default render and the class the anti-flash script already applied before hydration.