Skip to content

Components / UI

Theme Toggle

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).

Live demo

click · the page changes from the button
ThemeScript · 473 B raw, inline in <head>

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

1.0 kB raw

613 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.7 kB raw.

CSS 10.1 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 111 · Firefox 144 · Safari 18; needs view-transitions, clip-path. Elsewhere: an instant switch.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
// in the layout
import ThemeScript from '@moonarc/core/ThemeScript';
import ThemeToggle from '@moonarc/core/ThemeToggle';
---
<head>
  <ThemeScript />
</head>
<header>
  <ThemeToggle label />
</header>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime, Animated Icons, the base tokens and ThemeScript.

terminal
npx shadcn@latest add https://moonarc.dev/r/theme-toggle.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts, AnimatedIcons.astro, ThemeScript.astro and theme.ts.

ThemeToggle.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>

Props

PropTypeDefaultDescription
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.
labelbooleanfalsePrint the current choice next to the icon.

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.

Why it is built this way

Replaces: ThemeToggle (Skiper UI) · AnimatedThemeToggler (Magic UI) · theme toggle (shadcn). See the migration table.

Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself.

Chrome 52 · Firefox 97 · not Safariclick

page one

Iris.

page two

From the click.

Try the real swap →

Curtain

Page transitions

A full-viewport wipe between pages: the next page opens through an iris from where you clicked, or through blinds, a shutter, doors, pixels, staggered columns, a diagonal, or a title panel that carries the next page's name.

newly · Chrome 120 · Firefox 144 · Safari 18click

page one

Hero.

page two

Hero.

Try the real swap →

Page Transition

Page transitions

Animates a named region between pages: rise, zoom, wipe, scope, fade, slide, or the browser's own shared-element morph.

newly · Chrome 111 · Firefox 144 · Safari 18click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown