# Color Mode (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-color-mode` · version 1.0.0 · component · free
- category: Actions
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-color-mode.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-color-mode
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Light, Dark and System in one small control with a lit thumb that slides to the choice, or one round button that cycles. It applies the .dark class and remembers the choice, with System following the OS live, or drives your own theme code.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/color-mode.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `ColorMode`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-sans`, `--lumen-radius-k`, `--lumen-theme-index`. Never add Tailwind colour classes inside the component.

```tsx
import { ColorMode, type ThemeChoice } from "@/components/ui/lumen/controls/color-mode";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `ColorMode` | — | The control. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/color-mode.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- theme switcher, dark mode toggle, light dark system, color scheme, appearance setting, Lumen
- A site header or app settings: let people pick light, dark or follow the system

### Not when
- Apps with only one theme

## Mistakes
- To avoid a flash on load, set the class before paint (next-themes does); this control then only switches it

## Usage

```tsx
"use client";

import * as React from "react";
import { ColorMode, type ThemeChoice } from "@/components/ui/lumen/controls/color-mode";

// Self-contained: sets the theme on <html> and remembers it.
export function ThemeSwitch() {
  return <ColorMode />;
}

// Controlled, when something else owns the theme (with next-themes: value={theme} onValueChange={setTheme}).
export function ControlledThemeSwitch({ onChange }: { onChange: (theme: ThemeChoice) => void }) {
  const [theme, setTheme] = React.useState<ThemeChoice>("system");
  return (
    <ColorMode
      apply={false}
      value={theme}
      onValueChange={(next) => {
        setTheme(next);
        onChange(next);
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `look` | `"segmented" \| "toggle"` | `"segmented"` | Three choices side by side, or one button that cycles. |
| `showLabels` | `boolean` | `false` | Words beside the icons (segmented). |
| `value / defaultValue / onValueChange` | `"light" \| "dark" \| "system"` | `"system"` | Controlled or not. |
| `apply` | `boolean` | `true` | Set .dark on <html> and remember the choice. false: only report it (use with next-themes' setTheme). |
| `storageKey` | `string` | `"theme"` | Where the choice is remembered. |
| `labels` | `Partial<Record<choice, string>>` |  | The words, for other languages. |

Full docs: https://beautiful-ui.dev/components/lumen-color-mode

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Arrow keys | Move to and choose the next option (segmented) |
| Enter or Space | Cycle (toggle) |

## Motion inventory

| Interaction | What moves |
|---|---|
| Choose | The lit thumb springs to the choice |
| Cycle | The new icon turns in from a quarter turn |

## Accessibility contract (preserve when editing)
- Segmented is a radio group: one tab stop, arrow keys move and choose
- The toggle names the current theme and the next one

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-color-mode.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-color-mode.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-color-mode`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/color-mode.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/color-mode.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * Color Mode: Light, Dark and System as one small segmented control, with a lit thumb that slides
 * to the choice (a radio group: arrow keys move, the choice is announced). Two looks:
 *   - "segmented": all three side by side, icons with optional words;
 *   - "toggle": one round button that cycles, the icon turning as it changes.
 * By default it also applies the choice: the .dark class on <html> (the shadcn and next-themes
 * convention), remembered in localStorage, with System following the OS live. Pass apply={false}
 * and value/onValueChange to drive your own theme code (next-themes' setTheme, for example).
 *
 *   <ColorMode />
 *   <ColorMode look="toggle" />
 *   <ColorMode apply={false} value={theme} onValueChange={setTheme} />
 */

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

const ORDER: ThemeChoice[] = ["light", "dark", "system"];
const DEFAULT_LABELS: Record<ThemeChoice, string> = { light: "Light", dark: "Dark", system: "System" };

function Glyph({ choice }: { choice: ThemeChoice }) {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className={`lumen-color-mode-glyph`} data-choice={choice}>
      {choice === "light" && (
        <>
          <circle cx="8" cy="8" r="2.75" />
          <path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.05 1.05M11.35 11.35l1.05 1.05M3.6 12.4l1.05-1.05M11.35 4.65l1.05-1.05" />
        </>
      )}
      {choice === "dark" && <path d="M13.25 9.6A5.5 5.5 0 0 1 6.4 2.75a5.5 5.5 0 1 0 6.85 6.85Z" />}
      {choice === "system" && (
        <>
          <rect x="2" y="3" width="12" height="8.25" rx="1.75" />
          <path d="M6 14h4M8 11.25V14" />
        </>
      )}
    </svg>
  );
}

/** The stored choice, or null when nothing (valid) is stored. */
const read = (key: string): ThemeChoice | null => {
  try {
    const v = localStorage.getItem(key);
    return v === "light" || v === "dark" || v === "system" ? v : null;
  } catch {
    return null;
  }
};

// Every switcher on the page stays in step: a choice made in one is broadcast to the others.
const EVENT = `lumen-theme-change`;

function applyTheme(choice: ThemeChoice) {
  const 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";
}

function ColorMode({
  value: valueProp,
  defaultValue,
  onValueChange,
  apply = true,
  storageKey = "theme",
  look = "segmented",
  showLabels = false,
  labels: labelsProp,
  className,
  "aria-label": ariaLabel = "Theme",
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  value?: ThemeChoice | undefined;
  /** Used before anything is stored. Default "system". */
  defaultValue?: ThemeChoice | undefined;
  onValueChange?: ((value: ThemeChoice) => void) | undefined;
  /** Set the .dark class on <html> and remember the choice. Default true. */
  apply?: boolean;
  /** The localStorage key (next-themes uses "theme" too). */
  storageKey?: string;
  /** "segmented" (three choices) or "toggle" (one button that cycles). */
  look?: "segmented" | "toggle";
  /** Show the words beside the icons (segmented). */
  showLabels?: boolean;
  labels?: Partial<Record<ThemeChoice, string>> | undefined;
}) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp };
  const [own, setOwn] = React.useState<ThemeChoice>(defaultValue ?? "system");
  // Until the stored choice is read (after mount, so server and hydration agree), nothing is applied.
  const [ready, setReady] = React.useState(!apply || valueProp !== undefined);
  React.useEffect(() => {
    if (!apply || valueProp !== undefined) return;
    const stored = read(storageKey);
    queueMicrotask(() => {
      if (stored) setOwn(stored);
      setReady(true);
    });
    const sync = (e: Event) => {
      const v = (e as CustomEvent<{ key: string; value: ThemeChoice }>).detail;
      if (v.key === storageKey) setOwn(v.value);
    };
    window.addEventListener(EVENT, sync);
    return () => window.removeEventListener(EVENT, sync);
  }, [apply, storageKey, valueProp]);
  const value = valueProp ?? own;

  React.useEffect(() => {
    if (!apply || !ready) return;
    applyTheme(value);
    if (value !== "system") return;
    const mq = window.matchMedia("(prefers-color-scheme: dark)");
    const follow = () => applyTheme("system");
    mq.addEventListener("change", follow);
    return () => mq.removeEventListener("change", follow);
  }, [apply, ready, value]);

  const persist = React.useCallback(
    (v: ThemeChoice) => {
      try {
        localStorage.setItem(storageKey, v);
      } catch {
        /* storage blocked: the choice lasts for this visit */
      }
      window.dispatchEvent(new CustomEvent(EVENT, { detail: { key: storageKey, value: v } }));
    },
    [storageKey],
  );
  // Controlled: only a value the parent accepted is saved and shared with other switchers.
  React.useEffect(() => {
    if (apply && valueProp !== undefined) persist(valueProp);
  }, [apply, valueProp, persist]);
  const choose = (next: ThemeChoice) => {
    if (valueProp === undefined) {
      setOwn(next);
      if (apply) persist(next);
    }
    onValueChange?.(next);
  };

  if (look === "toggle") {
    const next = ORDER[(ORDER.indexOf(value) + 1) % ORDER.length] ?? "light";
    return (
      <div data-slot="color-mode" data-look="toggle" className={cn(`lumen-color-mode lumen-control`, className)} {...props}>
        <button type="button" className={`lumen-color-mode-toggle`} onClick={() => choose(next)} aria-label={`${ariaLabel}: ${labels[value]}. Switch to ${labels[next]}`}>
          <span key={value} className={`lumen-color-mode-turn`}>
            <Glyph choice={value} />
          </span>
        </button>
      </div>
    );
  }

  const index = ORDER.indexOf(value);
  const move = (e: React.KeyboardEvent, i: number) => {
    const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!step) return;
    e.preventDefault();
    const rtl = getComputedStyle(e.currentTarget).direction === "rtl" && (e.key === "ArrowRight" || e.key === "ArrowLeft");
    const to = ORDER[(i + (rtl ? -step : step) + ORDER.length) % ORDER.length] ?? "system";
    choose(to);
    (e.currentTarget.parentElement?.querySelector(`[data-choice="${to}"]`) as HTMLElement | null)?.focus();
  };
  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      data-slot="color-mode"
      data-look="segmented"
      data-labels={showLabels || undefined}
      className={cn(`lumen-color-mode lumen-control`, className)}
      style={{ "--lumen-theme-index": index } as React.CSSProperties}
      {...props}
    >
      <span aria-hidden className={`lumen-color-mode-thumb`} />
      {ORDER.map((c, i) => (
        <button
          key={c}
          type="button"
          role="radio"
          aria-checked={value === c}
          aria-label={showLabels ? undefined : labels[c]}
          tabIndex={value === c ? 0 : -1}
          data-choice={c}
          className={`lumen-color-mode-option`}
          onClick={() => choose(c)}
          onKeyDown={(e) => move(e, i)}
        >
          <Glyph choice={c} />
          {showLabels && <span>{labels[c]}</span>}
        </button>
      ))}
    </div>
  );
}

export { ColorMode, applyTheme };
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-color-mode[data-look="segmented"] {
    position: relative;
    display: inline-grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    padding: 3px;
    border-radius: calc(999px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 5%,transparent);
    box-shadow: inset 0 0 0 1px var(--gc-hair),inset 0 1px 2px rgba(0,0,0,.12);
  }
  .lumen-color-mode-thumb {
    position: absolute;
    inset-block: 3px;
    inset-inline-start: 3px;
    width: calc((100% - 6px) / 3);
    border-radius: calc(999px * var(--lumen-radius-k, 1));
    background: var(--gc-p-knob,#fff);
    box-shadow: inset 0 0 0 1px var(--gc-hair),0 1px 2px rgba(0,0,0,.18);
    transform: translateX(calc(var(--lumen-theme-index,0) * 100%));
    transition: transform calc(.22s * var(--gcp-k)) cubic-bezier(.2,.8,.2,1);
  }
  [dir="rtl"] .lumen-color-mode-thumb {
    transform: translateX(calc(var(--lumen-theme-index,0) * -100%));
  }
  .lumen-color-mode-option {
    position: relative;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    min-width: 34px;
    height: 28px;
    margin: 0;
    padding: 0 10px;
    border: 0;
    border-radius: calc(999px * var(--lumen-radius-k, 1));
    background: none;
    color: var(--gc-sec);
    font: 500 12.5px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    cursor: pointer;
    outline: none;
    transition: color min(0.2s, var(--gcp-cm)) ease;
  }
  .lumen-color-mode-option[aria-checked="true"] {
    color: var(--gc-p-thumbInk,var(--gc-ink));
  }
  .lumen-color-mode-option:focus-visible {
    box-shadow: 0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), 0 0 #0000;
  }
  .lumen-color-mode-glyph {
    width: 15px;
    height: 15px;
    flex: none;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.5;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .lumen-color-mode[data-look="toggle"] {
    display: inline-flex;
  }
  .lumen-color-mode-toggle {
    display: inline-grid;
    place-items: center;
    width: 34px;
    height: 34px;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: calc(999px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 5%,transparent);
    box-shadow: inset 0 0 0 1px var(--gc-hair);
    color: var(--gc-ink);
    cursor: pointer;
    outline: none;
    transition: background-color min(0.2s, var(--gcp-cm)) ease;
  }
  @media (hover:hover) {
    .lumen-color-mode-option:hover {
      color: var(--gc-ink);
    }
    .lumen-color-mode-toggle:hover {
      background: color-mix(in srgb,var(--gc-ink) 10%,transparent);
    }
  }
  .lumen-color-mode-toggle:focus-visible {
    box-shadow: inset 0 0 0 1px var(--gc-hair),0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), 0 0 #0000;
  }
  .lumen-color-mode-turn {
    display: inline-flex;
    animation: lumen-theme-turn calc(.5s * var(--gcp-k)) var(--gc-sp) both;
  }
  @keyframes lumen-theme-turn {
    from {
      opacity: 0;
      transform: rotate(-90deg) scale(.6);
    }
    to {
      opacity: 1;
      transform: none;
    }
  }
  .lumen-color-mode[data-motion="off"] .lumen-color-mode-turn {
    animation: none;
  }
  @media (prefers-reduced-motion:reduce) {
    .lumen-color-mode-turn {
      animation: none;
    }
    .lumen-color-mode-thumb {
      transition: none;
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
