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.
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
Name
Type
Default
Description
storage-key
string
mv-theme
localStorage key (empty = no persistence).
duration
number
650
Duration of the circular reveal (ms).
label
string
Dark theme
Accessible name of the icon button (state carried by aria-pressed).
data-variant
outline
Circled button with a background.
data-size
sm | lg
(md)
Button size.
data-state
light | dark
Set by the component: effective theme (for styling).
Properties
Name
Type
Description
theme
"light" | "dark"
Effective theme (read); setting it applies the theme with the animation.
Methods
Name
Description
setTheme(value, origin?)
"light" | "dark" | "system" (forgets the choice); origin = { x, y } in viewport px for the circle’s center.
Events
Name
Description
mv-change
detail = { theme, explicit } — explicit=false when reverting to the system preference.
Content structure
Name
Description
button (optional)
Provide your own <button>: the icon is added to it, and the styling stays yours.
CSS variables
Name
Default
Description
--mv-theme-toggle-size
var(--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).