# Theme Toggle (Moonarc)

Cycles system → light → dark, and the page changes theme through a circle that grows from the click, on a same-document view transition. ThemeScript in <head> stores the choice, applies it before paint and after every swap and exposes the API; ThemeToggle's own script only advances the choice and wraps the change (its storageKey prop is ThemeScript's). Without view transitions, or under reduced motion, the theme switches at once. Without ThemeScript, which includes a page without JavaScript, the toggle is hidden. The icon is AnimatedIcons (sun to moon).

- Import: `import ThemeToggle from '@moonarc/core/ThemeToggle'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/theme-toggle.json`
- Tier C · category ui · trigger click
- Readout: `<ThemeToggle origin="click">`
- Browser support: newly (Chrome 111 · Firefox 144 · Safari 18); elsewhere: an instant switch
- Measured cost: 1.0 kB raw JS · 613 B gzip · + runtime (with dependencies 2.7 kB raw; CSS 10.1 kB raw)
- Page: https://moonarc.dev/components/theme-toggle/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `origin` | `'click' | 'center'` | `'click'` | Where the circle grows from. |
| `choices` | `('system' | 'light' | 'dark')[]` | `['system', 'light', 'dark']` | The cycle; ["light", "dark"] for a two-state switch. A stored choice outside the cycle (system, for that switch) steps on from the theme it resolves to, so the first click always changes the page. |
| `label` | `boolean` | `false` | Print the current choice next to the icon. |

## Usage

```astro
---
// in the layout
import ThemeScript from '@moonarc/core/ThemeScript';
import ThemeToggle from '@moonarc/core/ThemeToggle';
---
<head>
  <ThemeScript />
</head>
<header>
  <ThemeToggle label />
</header>
```

## Reduced motion

No circle: the theme switches at once. The icon swap loses its rotation.

## With ClientRouter

ThemeScript re-applies the attributes after every swap (the router replaces <html>'s); the toggle rebinds through the shared runtime and re-syncs its label and icon.

## Craft

- One contract, two halves: ThemeScript (inline, in <head>, before paint) owns the attributes and the storage key; the toggle only calls set(). No flash on load, none on navigation. Without ThemeScript there is no data-theme-choice on <html>, and the toggle is display: none rather than a dead button or a second, half-right owner. The same rule hides it when JavaScript is off.
- The first frames are CSS: before the toggle's module has run, the label reads the choice from html[data-theme-choice] and the icon turns to the moon under html[data-theme="dark"], so a reader who chose dark never sees a sun and the word system flash first.
- The circle is clip-path on ::view-transition-new(root) with the old snapshot held still. The new theme is revealed, not faded, so mid-transition both themes are crisp.
- The radius is computed to the farthest corner, so the circle covers the viewport exactly when the transition ends, from any click point.
- The attribute that selects the transition rules lives on <html> only for the duration and outranks Curtain's :where() root rules, so a page can carry both.
- Three choices: system is a choice, and a visitor who chose it should not be forced into a fixed theme by a click.

## Replaces

- ThemeToggle (Skiper UI)
- AnimatedThemeToggler (Magic UI)
- theme toggle (shadcn)

## Source

```astro
---
/**
 * ThemeToggle — cycles system → light → dark and the page changes theme
 * through a circle that grows from the click. The choice is stored under
 * ThemeScript's key; ThemeScript in <head> is what applies it before paint
 * and after every swap and exposes window.__maTheme, so this script only
 * advances the choice, writes the origin on <html> and wraps the change in
 * document.startViewTransition. Without ThemeScript, and so without
 * JavaScript, the toggle is hidden: it could only be a dead button.
 * Without view transitions, or under reduced motion, the theme switches
 * at once. The icon is AnimatedIcons (sun ↔ moon) reading data-on; until
 * the script has synced, the icon and the label follow ThemeScript's
 * attributes on <html>, so the first frames already show the choice.
 */
import type { HTMLAttributes } from 'astro/types';
import AnimatedIcons from './AnimatedIcons.astro';

type Choice = 'system' | 'light' | 'dark';

interface Props extends HTMLAttributes<'button'> {
  /** Where the circle grows from. */
  origin?: 'click' | 'center';
  /** The cycle. */
  choices?: Choice[];
  /** Print the current choice next to the icon. */
  label?: boolean;
}

const { origin = 'click', choices = ['system', 'light', 'dark'], label = false, class: className, ...rest } = Astro.props;
---

<button
  class:list={['ma-theme', className]}
  type="button"
  data-origin={origin}
  data-choices={choices.join(',')}
  aria-label="Theme: system"
  {...rest}
>
  <AnimatedIcons icon="sun" />
  {label && <span class="ma-theme__label"></span>}
</button>

<style is:global>
  @layer components {
    :where(.ma-theme) {
      display: inline-flex;
      align-items: center;
      gap: 0.5em;
      padding: 0.45em 0.7em;
      border: 1px solid var(--ma-edge);
      border-radius: 0.5em;
      background: var(--ma-panel);
      color: var(--ma-ink);
      font: inherit;
      font-size: 0.875em;
      line-height: 1.2;
      cursor: pointer;
      transition: border-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    /* no ThemeScript (no data-theme-choice), which includes no JavaScript at all: a toggle that cannot toggle is not shown */
    :where(:root:not([data-theme-choice]) .ma-theme) {
      display: none;
    }
    :where(.ma-theme:hover) {
      border-color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
    }
    :where(.ma-theme:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    :where(.ma-theme__label) {
      font-family: ui-monospace, monospace;
      font-size: 0.85em;
      min-width: 3.6em;
      text-align: left;
    }
    /* the first frames, before the script has synced: ThemeScript has already stamped the choice and the resolved theme
       on <html>, so the label reads the choice and the icon is the moon in dark. The script then writes the label's
       text (:empty lets go) and data-on. The icon rules outrank AnimatedIcons' own zero-specificity ones, so the order
       the two stylesheets arrive in does not matter */
    :where(.ma-theme__label:empty)::before {
      content: 'system';
    }
    :where(:root[data-theme-choice='light'] .ma-theme__label:empty)::before {
      content: 'light';
    }
    :where(:root[data-theme-choice='dark'] .ma-theme__label:empty)::before {
      content: 'dark';
    }
    :where(:root[data-theme='dark'] .ma-theme .ma-icon[data-icon='sun']) .ma-icon__a,
    :where(:root[data-theme='dark'] .ma-theme .ma-icon[data-icon='sun']) .ma-icon__b {
      opacity: 0;
      scale: 0.5;
      rotate: 90deg;
    }
    :where(:root[data-theme='dark'] .ma-theme .ma-icon[data-icon='sun']) .ma-icon__c {
      opacity: 1;
      scale: 1;
      rotate: 0deg;
    }
    /* the swap: the old theme holds, the new one opens as a circle from the origin. The attribute lives on <html>
       only for the duration, and its specificity beats Curtain's :where() root rules so both can share a page. */
    :where(:root)[data-ma-theme-vt]::view-transition-old(root) {
      animation: none;
      mix-blend-mode: normal;
    }
    :where(:root)[data-ma-theme-vt]::view-transition-new(root) {
      animation: ma-theme-iris var(--ma-duration) var(--ma-ease-out) both;
      mix-blend-mode: normal;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(:root)[data-ma-theme-vt]::view-transition-old(root),
      :where(:root)[data-ma-theme-vt]::view-transition-new(root) {
        animation: none;
        clip-path: none;
      }
      :where(:root)[data-ma-theme-vt]::view-transition-old(root) {
        display: none;
      }
    }
  }

  @keyframes ma-theme-iris {
    from {
      clip-path: circle(0 at var(--ma-theme-x, 50%) var(--ma-theme-y, 50%));
    }
    to {
      clip-path: circle(var(--ma-theme-r, 150%) at var(--ma-theme-x, 50%) var(--ma-theme-y, 50%));
    }
  }
</style>

<script>
  import { onMount, prefersReducedMotion } from '../lib/runtime';

  type Api = { choice(): string; set(c: string): void };
  const html = document.documentElement;
  // every toggle on the page follows a change made on any of them
  const syncs = new Set<() => void>();

  // bound by tag and class, not by an attribute: data-ma-theme is theme.css's scope (7F) — a toggle bound to it turned every
  // themed subtree (a section demo, the /theme preview) into a second toggle that cycled the theme on any click inside it
  onMount<HTMLButtonElement>('button.ma-theme', (btn, { signal }) => {
    // ThemeScript owns the attributes and exposes the API; without it the toggle stays inert rather than half-working
    const theme = (window as unknown as { __maTheme?: Api }).__maTheme;
    if (!theme) return;
    const choices = (btn.dataset.choices ?? 'system,light,dark').split(',');
    const sync = () => {
      const c = theme.choice();
      btn.setAttribute('aria-label', `Theme: ${c}`);
      const l = btn.querySelector('.ma-theme__label');
      if (l) l.textContent = c;
      btn.toggleAttribute('data-on', html.dataset.theme === 'dark');
    };
    syncs.add(sync);
    signal.addEventListener('abort', () => syncs.delete(sync));
    btn.addEventListener(
      'click',
      () => {
        // a choice outside the cycle (system, with choices light and dark) steps on from the theme it resolved to, so the
        // first click changes the page instead of setting the theme it already shows
        const next = choices[(choices.indexOf(theme.choice()) + 1 || choices.indexOf(html.dataset.theme!) + 1) % choices.length]!;
        const apply = () => {
          theme.set(next);
          syncs.forEach((s) => s());
        };
        const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
        if (!doc.startViewTransition || prefersReducedMotion()) return apply();
        const r = btn.getBoundingClientRect();
        const centre = btn.dataset.origin === 'center';
        const x = centre ? innerWidth / 2 : r.left + r.width / 2;
        const y = centre ? innerHeight / 2 : r.top + r.height / 2;
        html.style.cssText += `;--ma-theme-x:${x}px;--ma-theme-y:${y}px;--ma-theme-r:${Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))}px`;
        html.setAttribute('data-ma-theme-vt', '');
        doc.startViewTransition(apply).finished.finally(() => html.removeAttribute('data-ma-theme-vt'));
      },
      { signal },
    );
    sync();
  });
</script>

```
