Theme Toggle — <mv-theme-toggle>

Light/dark toggle: the new theme is revealed in a circle from the button (View Transition + clip-path), a sun morphs into a moon, the choice is remembered and the system preference respected.

CategoryMicro-interactions
TypeWeb Component (<mv-theme-toggle>)
Statusstable
Keywordstheme, dark-mode, light-mode, view-transition, toggle, sun, moon

When to use

Avoid when

Install

node scripts/add.mjs theme-toggle --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["theme-toggle"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/theme-toggle/theme-toggle.js, components/theme-toggle/theme-toggle.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div style="width:100%;max-width:520px;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);box-shadow:var(--mv-shadow-md);overflow:hidden">
  <header style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 1rem;border-bottom:1px solid var(--mv-border)">
    <strong style="display:flex;align-items:center;gap:.5rem;font-size:.95rem">
      <span style="width:1.5rem;height:1.5rem;border-radius:.4rem;background:linear-gradient(135deg,var(--mv-accent),var(--mv-accent-fg))"></span>
      Lumen Studio
    </strong>
    <nav style="display:flex;align-items:center;gap:.25rem;font-size:.85rem;color:var(--mv-fg-muted)">
      <a href="#" style="color:inherit;text-decoration:none;padding:.35rem .6rem">Projects</a>
      <a href="#" style="color:inherit;text-decoration:none;padding:.35rem .6rem">Journal</a>
      <mv-theme-toggle></mv-theme-toggle>
    </nav>
  </header>
  <div style="padding:1.25rem 1rem;display:grid;gap:.75rem">
    <p style="margin:0;font-size:.9rem;color:var(--mv-fg-muted)">The new theme is revealed in a circle from the button. The choice is remembered; without one, the system decides.</p>
    <div style="display:flex;align-items:center;gap:.75rem;font-size:.85rem">
      <mv-theme-toggle data-variant="outline" data-size="lg"></mv-theme-toggle>
      <mv-theme-toggle data-variant="outline" data-size="sm"></mv-theme-toggle>
      <span style="color:var(--mv-fg-subtle)">Outline variants, large and small</span>
    </div>
  </div>
</div>

API

Attributes

NameTypeDefaultDescription
storage-keystringmv-themelocalStorage key (empty = no persistence).
durationnumber650Duration of the circular reveal (ms).
labelstringDark themeAccessible name of the icon button (state carried by aria-pressed).
data-variantoutlineCircled button with a background.
data-sizesm | lg(md)Button size.
data-statelight | darkSet by the component: effective theme (for styling).

Properties

NameTypeDescription
theme"light" | "dark"Effective theme (read); setting it applies the theme with the animation.

Methods

NameDescription
setTheme(value, origin?)"light" | "dark" | "system" (forgets the choice); origin = { x, y } in viewport px for the circle’s center.

Events

NameDescription
mv-changedetail = { theme, explicit } — explicit=false when reverting to the system preference.

Content structure

NameDescription
button (optional)Provide your own <button>: the icon is added to it, and the styling stays yours.

CSS variables

NameDefaultDescription
--mv-theme-toggle-sizevar(--mv-control-md)Button size (square side).

Accessibility

A real <button> with aria-pressed (true = dark) and the accessible name “Dark theme”. With the keyboard, the circle starts from the button’s center. Reduced motion or a browser without View Transitions: instant switch, the icon flips without animation. To avoid a flash on load, copy this into the <head>: <script>try{const t=localStorage.getItem('mv-theme');if(t)document.documentElement.dataset.theme=t}catch{}</script>. Stays in sync if data-theme changes elsewhere (another button, another tab, system preference).