# Theme switchers

> Three appearance controls — a light/dark/system segmented switch with a sliding thumb, an appearance card with live mini previews, and an accent and density picker — that report changes without touching your site.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/theme-switchers
- Install: `npx shadcn@latest add @geturui/theme-switchers`
- Dependencies: motion, lucide-react

## Variants

### Segmented

Light, dark and system options with a thumb that slides between them and an icon that turns as it lands; text or icon-only.

Install: `npx shadcn@latest add @geturui/theme-switchers-segmented`

```tsx
import { ThemeSegmented } from "@/components/examples/theme-switchers/segmented";

export function ThemeToggle() {
  return <ThemeSegmented defaultValue="system" onChange={(t) => applyTheme(t)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | `"light" | "dark" | "system"` | "system" | Controlled or initial choice. |
| onChange | `(theme: ThemeChoice) => void` | — | Fires with the new choice. |
| iconOnly | `boolean` | false | Hide labels for toolbars. |
| size | `"sm" | "md"` | "md" | Control height. |
| className | `string` | — | Extra classes on the root element. |

### Appearance card

Theme cards with tiny app windows drawn in each theme's real tokens, a check that pops onto the chosen one and a stepped text-size slider.

Install: `npx shadcn@latest add @geturui/theme-switchers-appearance-card`

```tsx
import { AppearanceCard } from "@/components/examples/theme-switchers/appearance-card";

export function AppearanceSettings() {
  return <AppearanceCard onChange={({ theme, textSize }) => savePrefs(theme, textSize)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| defaultValue | `{ theme; textSize }` | dark, 100 | Initial theme and text size in percent. |
| onChange | `(value: AppearanceSettings) => void` | — | Fires with every change. |
| className | `string` | — | Extra classes on the root element. |

### Accent and density

Accent swatches and a density control beside a live preview whose rows re-space with a layout spring and repaint in the chosen accent.

Install: `npx shadcn@latest add @geturui/theme-switchers-accent-density`

```tsx
import { AccentDensityPicker } from "@/components/examples/theme-switchers/accent-density";

export function ThemeSettings() {
  return <AccentDensityPicker onChange={({ accent, density }) => savePrefs(accent, density)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| defaultValue | `{ accent; density }` | volt, default | Initial accent and density. |
| onChange | `(value: AccentDensityValue) => void` | — | Fires with every change; only the preview updates. |
| className | `string` | — | Extra classes on the root element. |

## Accessibility

- Every control is a radiogroup with one tab stop; arrow keys move and select, Home and End jump to the ends.
- The text size slider is a native range input with a spoken percentage.
- Choices only change local state and fire onChange, so nothing on the host page shifts unexpectedly; motion reduces to fades.

## Source

### components/examples/theme-switchers/segmented.tsx

```tsx
"use client";

import * as React from "react";
import { motion } from "motion/react";
import { Monitor, Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";

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

export interface ThemeSegmentedProps {
  value?: ThemeChoice;
  defaultValue?: ThemeChoice;
  /** Fires with the new choice. Nothing on the page changes unless you apply it. */
  onChange?: (theme: ThemeChoice) => void;
  /** Icons only, for toolbars. */
  iconOnly?: boolean;
  size?: "sm" | "md";
  className?: string;
}

const OPTIONS = [
  { id: "light", label: "Light", Icon: Sun },
  { id: "dark", label: "Dark", Icon: Moon },
  { id: "system", label: "System", Icon: Monitor },
] as const;
const spring = { type: "spring", stiffness: 460, damping: 34 } as const;

/** Light / dark / system radio group with a thumb that slides between options and an icon that turns as it lands. */
export function ThemeSegmented({ value, defaultValue = "system", onChange, iconOnly = false, size = "md", className }: ThemeSegmentedProps) {
  const uid = React.useId();
  const [inner, setInner] = React.useState<ThemeChoice>(defaultValue);
  const current = value ?? inner;
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);

  function choose(id: ThemeChoice, focus = false) {
    if (value === undefined) setInner(id);
    onChange?.(id);
    if (focus) refs.current[OPTIONS.findIndex((o) => o.id === id)]?.focus();
  }

  function onKey(e: React.KeyboardEvent) {
    const i = OPTIONS.findIndex((o) => o.id === current);
    const n = OPTIONS.length;
    const map: Record<string, number> = { ArrowRight: (i + 1) % n, ArrowDown: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, ArrowUp: (i - 1 + n) % n, Home: 0, End: n - 1 };
    if (!(e.key in map)) return;
    e.preventDefault();
    choose(OPTIONS[map[e.key]].id, true);
  }

  const sm = size === "sm";

  return (
    <div role="radiogroup" aria-label="Theme" onKeyDown={onKey} className={cn("inline-flex rounded-full border border-border bg-surface-sunken p-1", className)}>
      {OPTIONS.map(({ id, label, Icon }, i) => {
        const on = current === id;
        return (
          <motion.button
            key={id}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={on}
            aria-label={iconOnly ? label : undefined}
            tabIndex={on ? 0 : -1}
            whileTap={{ scale: 0.94 }}
            onClick={() => choose(id)}
            className={cn(
              "relative flex items-center justify-center gap-1.5 rounded-full font-medium outline-none transition-colors focus-visible:shadow-[var(--focus-ring)]",
              sm ? "h-7 text-[12px]" : "h-9 text-[13px]",
              iconOnly ? (sm ? "w-7" : "w-9") : sm ? "px-2.5" : "px-3.5",
              on ? "text-ink" : "text-ink-subtle hover:text-ink",
            )}
          >
            {on && <motion.span layoutId={`${uid}-thumb`} transition={spring} className="absolute inset-0 rounded-full border border-border bg-surface-raised shadow-sm" />}
            <motion.span className="relative" animate={{ rotate: on ? (id === "light" ? 90 : id === "dark" ? -20 : 0) : 0 }} transition={spring}>
              <Icon aria-hidden className={sm ? "size-3.5" : "size-4"} />
            </motion.span>
            {!iconOnly && <span className="relative">{label}</span>}
          </motion.button>
        );
      })}
    </div>
  );
}

```

### components/examples/theme-switchers/appearance-card.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";

export type AppearanceTheme = "light" | "dark" | "system";

export interface AppearanceSettings {
  theme: AppearanceTheme;
  /** Interface text scale in percent. */
  textSize: number;
}

export interface AppearanceCardProps {
  defaultValue?: AppearanceSettings;
  /** Fires with every change. The card only previews; apply the result yourself. */
  onChange?: (value: AppearanceSettings) => void;
  className?: string;
}

const THEMES = [
  { id: "light", label: "Light" },
  { id: "dark", label: "Dark" },
  { id: "system", label: "System" },
] as const;
const SIZES = [90, 100, 110, 125];
const DEFAULT: AppearanceSettings = { theme: "dark", textSize: 100 };
const spring = { type: "spring", stiffness: 420, damping: 30 } as const;

/** A tiny app window drawn with tokens, scoped to one theme with data-theme. */
function Mini({ scheme }: { scheme: "light" | "dark" }) {
  return (
    <div data-theme={scheme} className="flex h-full w-full gap-1.5 bg-bg p-2">
      <div className="flex w-1/4 flex-col gap-1 rounded-[4px] bg-surface p-1">
        <span className="h-1.5 w-full rounded-full bg-volt" />
        <span className="h-1 w-3/4 rounded-full bg-ink-subtle/50" />
        <span className="h-1 w-2/3 rounded-full bg-ink-subtle/50" />
      </div>
      <div className="flex flex-1 flex-col gap-1 rounded-[4px] border border-border bg-surface-raised p-1.5">
        <span className="h-1.5 w-2/3 rounded-full bg-ink" />
        <span className="h-1 w-full rounded-full bg-ink-subtle/40" />
        <span className="h-1 w-5/6 rounded-full bg-ink-subtle/40" />
        <span className="mt-auto h-2.5 w-8 rounded-full bg-volt" />
      </div>
    </div>
  );
}

/** Appearance settings with mini window previews for each theme, a springing check on the chosen one and a live text-size sample. */
export function AppearanceCard({ defaultValue = DEFAULT, onChange, className }: AppearanceCardProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const [value, setValue] = React.useState(defaultValue);
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);

  function update(next: Partial<AppearanceSettings>) {
    const v = { ...value, ...next };
    setValue(v);
    onChange?.(v);
  }

  function onKey(e: React.KeyboardEvent) {
    const i = THEMES.findIndex((t) => t.id === value.theme);
    const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!step) return;
    e.preventDefault();
    const n = (i + step + THEMES.length) % THEMES.length;
    update({ theme: THEMES[n].id });
    refs.current[n]?.focus();
  }

  const sizeIndex = SIZES.indexOf(value.textSize);

  return (
    <section aria-labelledby={`${uid}-t`} className={cn("w-full max-w-[520px] rounded-lg border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <h2 id={`${uid}-t`} className="font-display text-[16px] font-semibold text-ink">Appearance</h2>
      <p className="mt-0.5 text-[12.5px] text-ink-muted">Choose how the workspace looks on this device.</p>

      <div role="radiogroup" aria-label="Theme" onKeyDown={onKey} className="mt-4 grid grid-cols-3 gap-2.5 sm:gap-3">
        {THEMES.map(({ id, label }, i) => {
          const on = value.theme === id;
          return (
            <motion.button
              key={id}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={on}
              tabIndex={on ? 0 : -1}
              whileTap={{ scale: 0.97 }}
              onClick={() => update({ theme: id })}
              className="group flex flex-col gap-2 rounded-md text-left outline-none focus-visible:shadow-[var(--focus-ring)]"
            >
              <span className={cn("relative block aspect-[4/3] w-full overflow-hidden rounded-md border-2 transition-colors", on ? "border-volt-ink" : "border-border group-hover:border-border-strong")}>
                {id === "system" ? (
                  <>
                    <span className="absolute inset-0"><Mini scheme="light" /></span>
                    <span className="absolute inset-0 [clip-path:polygon(100%_0,100%_100%,0_100%)]"><Mini scheme="dark" /></span>
                  </>
                ) : (
                  <Mini scheme={id} />
                )}
                <AnimatePresence>
                  {on && (
                    <motion.span initial={{ scale: 0, rotate: reduce ? 0 : -30 }} animate={{ scale: 1, rotate: 0 }} exit={{ scale: 0 }} transition={{ type: "spring", stiffness: 520, damping: 18 }} className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded-full bg-volt text-on-volt shadow-sm">
                      <Check aria-hidden className="size-3" strokeWidth={3} />
                    </motion.span>
                  )}
                </AnimatePresence>
              </span>
              <span className={cn("px-0.5 text-[12.5px] font-medium", on ? "text-ink" : "text-ink-muted")}>{label}</span>
            </motion.button>
          );
        })}
      </div>

      <div className="mt-5 border-t border-border pt-4">
        <div className="flex items-center justify-between">
          <label htmlFor={`${uid}-size`} className="text-[13px] font-medium text-ink">Text size</label>
          <span className="font-mono text-[12px] tabular-nums text-ink-muted">{value.textSize}%</span>
        </div>
        <div className="mt-3 flex items-center gap-3">
          <span aria-hidden className="text-[11px] font-semibold text-ink-subtle">A</span>
          <div className="relative flex-1">
            <div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-surface-sunken ring-1 ring-border" />
            <motion.div className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-volt" animate={{ width: `${(sizeIndex / (SIZES.length - 1)) * 100}%` }} transition={spring} />
            <div className="relative flex justify-between">
              {SIZES.map((s, i) => (
                <span key={s} aria-hidden className={cn("size-2 rounded-full", i <= sizeIndex ? "bg-volt" : "bg-border-strong")} />
              ))}
            </div>
            <input
              id={`${uid}-size`}
              type="range"
              min={0}
              max={SIZES.length - 1}
              step={1}
              value={sizeIndex}
              aria-valuetext={`${value.textSize} percent`}
              onChange={(e) => update({ textSize: SIZES[Number(e.target.value)] })}
              className="peer absolute inset-0 h-full w-full cursor-pointer opacity-0"
            />
            <motion.span
              aria-hidden
              className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-volt bg-surface-raised shadow-md peer-focus-visible:shadow-[var(--focus-ring)]"
              animate={{ left: `${(sizeIndex / (SIZES.length - 1)) * 100}%` }}
              transition={spring}
            />
          </div>
          <span aria-hidden className="text-[16px] font-semibold text-ink-subtle">A</span>
        </div>
        <motion.p layout={!reduce} className="mt-4 rounded-md border border-border bg-surface-sunken px-3 py-2.5 text-ink-muted" style={{ fontSize: `${(13 * value.textSize) / 100}px` }}>
          The quick review is ready. Leave comments by Friday.
        </motion.p>
      </div>
    </section>
  );
}

```

### components/examples/theme-switchers/accent-density.tsx

```tsx
"use client";

import * as React from "react";
import { motion } from "motion/react";
import { Check, FileText, Folder, Image as ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";

export type Accent = "volt" | "sky" | "violet" | "rose" | "amber" | "graphite";
export type Density = "compact" | "default" | "comfortable";

export interface AccentDensityValue {
  accent: Accent;
  density: Density;
}

export interface AccentDensityPickerProps {
  defaultValue?: AccentDensityValue;
  /** Fires with every change. Only the preview updates; apply it to your app yourself. */
  onChange?: (value: AccentDensityValue) => void;
  className?: string;
}

const ACCENTS: { id: Accent; label: string; fill: string; text: string; soft: string }[] = [
  { id: "volt", label: "Volt", fill: "bg-volt", text: "text-on-volt", soft: "bg-volt-soft" },
  { id: "sky", label: "Sky", fill: "bg-sky-500", text: "text-white", soft: "bg-sky-500/15" },
  { id: "violet", label: "Violet", fill: "bg-violet-500", text: "text-white", soft: "bg-violet-500/15" },
  { id: "rose", label: "Rose", fill: "bg-rose-500", text: "text-white", soft: "bg-rose-500/15" },
  { id: "amber", label: "Amber", fill: "bg-amber-400", text: "text-neutral-950", soft: "bg-amber-400/20" },
  { id: "graphite", label: "Graphite", fill: "bg-ink", text: "text-bg", soft: "bg-surface-hover" },
];
const DENSITIES: { id: Density; label: string; row: string }[] = [
  { id: "compact", label: "Compact", row: "py-1" },
  { id: "default", label: "Default", row: "py-2" },
  { id: "comfortable", label: "Comfortable", row: "py-3" },
];
const FILES = [
  { name: "Brand guidelines", meta: "12 files", Icon: Folder },
  { name: "Q4 roadmap.pdf", meta: "2.4 MB", Icon: FileText },
  { name: "Launch hero.png", meta: "860 KB", Icon: ImageIcon },
];
const DEFAULT: AccentDensityValue = { accent: "volt", density: "default" };
const spring = { type: "spring", stiffness: 420, damping: 32 } as const;

/** Accent colour and density picker with a live preview: rows re-space with a layout spring and the accent repaints the selection. */
export function AccentDensityPicker({ defaultValue = DEFAULT, onChange, className }: AccentDensityPickerProps) {
  const uid = React.useId();
  const [value, setValue] = React.useState(defaultValue);
  const accentRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const densityRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const A = ACCENTS.find((a) => a.id === value.accent) ?? ACCENTS[0];
  const D = DENSITIES.find((d) => d.id === value.density) ?? DENSITIES[1];

  function update(next: Partial<AccentDensityValue>) {
    const v = { ...value, ...next };
    setValue(v);
    onChange?.(v);
  }

  function roving<T extends { id: string }>(e: React.KeyboardEvent, list: T[], current: string, refs: React.RefObject<(HTMLButtonElement | null)[]>, pick: (id: T["id"]) => void) {
    const i = list.findIndex((x) => x.id === current);
    const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!step) return;
    e.preventDefault();
    const n = (i + step + list.length) % list.length;
    pick(list[n].id);
    refs.current[n]?.focus();
  }

  return (
    <section aria-labelledby={`${uid}-t`} className={cn("grid w-full max-w-[640px] overflow-hidden rounded-lg border border-border bg-surface-raised shadow-sm sm:grid-cols-[1fr_260px]", className)}>
      <div className="space-y-5 p-5">
        <div>
          <h2 id={`${uid}-t`} className="font-display text-[16px] font-semibold text-ink">Theme</h2>
          <p className="mt-0.5 text-[12.5px] text-ink-muted">Pick an accent and how much room things get.</p>
        </div>
        <div>
          <p id={`${uid}-a`} className="text-[12px] font-medium text-ink-muted">Accent · <span className="text-ink">{A.label}</span></p>
          <div role="radiogroup" aria-labelledby={`${uid}-a`} onKeyDown={(e) => roving(e, ACCENTS, value.accent, accentRefs, (id) => update({ accent: id }))} className="mt-2.5 flex flex-wrap gap-2.5">
            {ACCENTS.map((a, i) => {
              const on = a.id === value.accent;
              return (
                <motion.button
                  key={a.id}
                  ref={(el) => {
                    accentRefs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={on}
                  aria-label={a.label}
                  tabIndex={on ? 0 : -1}
                  whileTap={{ scale: 0.88 }}
                  onClick={() => update({ accent: a.id })}
                  className={cn("relative grid size-8 place-items-center rounded-full outline-none ring-offset-2 ring-offset-surface-raised transition-shadow focus-visible:shadow-[var(--focus-ring)]", on && "ring-2 ring-border-strong")}
                >
                  <span className={cn("absolute inset-0 rounded-full shadow-sm ring-1 ring-inset ring-border", a.fill)} />
                  {on && (
                    <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 600, damping: 20 }} className={cn("relative", a.text)}>
                      <Check aria-hidden className="size-4" strokeWidth={3} />
                    </motion.span>
                  )}
                </motion.button>
              );
            })}
          </div>
        </div>
        <div>
          <p id={`${uid}-d`} className="text-[12px] font-medium text-ink-muted">Density</p>
          <div role="radiogroup" aria-labelledby={`${uid}-d`} onKeyDown={(e) => roving(e, DENSITIES, value.density, densityRefs, (id) => update({ density: id }))} className="mt-2.5 grid grid-cols-3 rounded-full border border-border bg-surface-sunken p-1">
            {DENSITIES.map((d, i) => {
              const on = d.id === value.density;
              return (
                <button
                  key={d.id}
                  ref={(el) => {
                    densityRefs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={on}
                  tabIndex={on ? 0 : -1}
                  onClick={() => update({ density: d.id })}
                  className={cn("relative h-8 rounded-full text-[12.5px] font-medium outline-none focus-visible:shadow-[var(--focus-ring)]", on ? "text-ink" : "text-ink-subtle hover:text-ink")}
                >
                  {on && <motion.span layoutId={`${uid}-den`} transition={spring} className="absolute inset-0 rounded-full border border-border bg-surface-raised shadow-sm" />}
                  <span className="relative">{d.label}</span>
                </button>
              );
            })}
          </div>
        </div>
      </div>

      <div aria-label="Preview" role="img" className="border-t border-border bg-surface p-4 sm:border-l sm:border-t-0">
        <p className="text-[11px] font-medium uppercase tracking-wide text-ink-subtle">Preview</p>
        <motion.ul layout className="mt-2.5 rounded-md border border-border bg-surface-raised p-1">
          {FILES.map(({ name, meta, Icon }, i) => (
            <motion.li key={name} layout transition={spring} className={cn("relative flex items-center gap-2.5 rounded-sm px-2", D.row)}>
              {i === 1 && <motion.span layout transition={spring} className={cn("absolute inset-0 rounded-sm", A.soft)} />}
              <Icon aria-hidden className={cn("relative size-4", i === 1 ? "text-ink" : "text-ink-subtle")} />
              <span className="relative flex-1 truncate text-[12.5px] text-ink">{name}</span>
              <span className="relative text-[11px] text-ink-subtle">{meta}</span>
            </motion.li>
          ))}
        </motion.ul>
        <motion.div layout transition={spring} className="mt-3">
          <div className="flex justify-between text-[11px] text-ink-muted">
            <span>Storage</span>
            <span className="tabular-nums">68%</span>
          </div>
          <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-border">
            <motion.div layout className={cn("h-full w-[68%] rounded-full transition-colors duration-300", A.fill)} />
          </div>
        </motion.div>
        <motion.div layout transition={spring} className={cn("mt-3 grid h-8 place-items-center rounded-full text-[12.5px] font-semibold transition-colors duration-300", A.fill, A.text)}>
          Share folder
        </motion.div>
      </div>
    </section>
  );
}

```
