---
name: nikuma-theme-switcher
description: Add a system / light / dark theme switcher that remembers the choice and reveals the new theme as a growing circle. Use when the user asks for this effect or component in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---

# Theme switcher

Three buttons: match my device, day, night. The new theme grows as a circle out of the button you pressed. Try it: this demo changes the real theme of this site.

One dependency-free file. `mountThemeSwitcher(element, options)` builds everything inside `element` and **returns a `stop()` function** that removes it and cleans up all listeners. The framework wrappers below only call it in the right lifecycle hook.

## When to use

The user wants: system, day or night with a circle reveal. Works in plain HTML, Astro, React (incl. Next.js), Vue (incl. Nuxt), Svelte (incl. SvelteKit) and Solid.

## How to add it

1. Save the file(s) under **Source** into the project (for example `src/lib/theme-switcher/`), keeping their names. They need no installation.
2. Vanilla / Astro / plain HTML: import the function in a `<script type="module">` and call it with the container element. Other frameworks: create the wrapper from **Framework wrappers**, then render it.
3. Give the container a size where the rules below say so, then check it in both light and dark theme.

```js
import { mountThemeSwitcher } from './theme-switcher.js';
const stop = mountThemeSwitcher(document.getElementById('stage'), { storageKey: 'theme' });
// stop();  // when the element is removed
```

## Options (all optional)

| Option | Default | What it does |
|---|---|---|
| `storageKey` | `'theme'` | where the choice is saved |
| `default` | `'system'` | 'system' | 'light' | 'dark' for first-time visitors |
| `reveal` | `true` | false = switch instantly, no circle |

## Rules

- The component needs no container size.
- Use the same `storageKey` everywhere on the same site.
- Call `stop()` before mounting again on the same element (the framework wrappers do this on unmount).
- Do not edit the engine to change behaviour; use options. Respect `prefers-reduced-motion` (the engine does).

## Source

### `theme-switcher.js`

```js
// Theme switcher: system / day / night. The choice is remembered. Switching grows a circle of the new theme out of the button you pressed.
// mountThemeSwitcher(el, options) -> stop()
//
// What it does to your page: sets  <html data-theme="light|dark">  (and data-pref="system|light|dark"). Style your colours with
//   :root{--bg:#fff} :root[data-theme="dark"]{--bg:#111}      and you are done.
//
// options (all optional):
//   storageKey  where the choice is saved in localStorage       default 'theme'
//   default     'system' | 'light' | 'dark'  first visit         default 'system'
//   reveal      false = no circle animation                     default true
//
// To avoid a flash of the wrong theme on page load, also put this tiny script in your <head> (before your CSS):
//   <script>(function(){var t;try{t=localStorage.getItem('theme')}catch(e){}t=t||'system';document.documentElement.dataset.theme=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t})()</script>
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };
const ICONS = {
  system: '<rect x="3" y="4" width="18" height="12" rx="2.5"/><path d="M8 20h8M12 16v4"/>',
  light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
  dark: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
};
const LABEL = { system: 'Match my device', light: 'Day theme', dark: 'Night theme' };

export function mountThemeSwitcher(el, o = {}) {
  css('tsx-css', `.tsx{position:relative;display:inline-flex;gap:2px;padding:3px;border-radius:999px;background:rgba(127,127,127,.16)}
.tsx button{position:relative;z-index:1;display:grid;place-items:center;width:34px;height:30px;border:0;border-radius:999px;background:none;color:inherit;opacity:.6;cursor:pointer;transition:opacity .25s}
.tsx button[aria-pressed="true"]{opacity:1}.tsx button:hover{opacity:1}.tsx svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.tsx i{position:absolute;z-index:0;top:3px;left:3px;width:34px;height:30px;border-radius:999px;background:rgba(127,127,127,.28);transition:transform .35s cubic-bezier(.2,.7,.1,1)}
:root.tsx-vt::view-transition-old(root),:root.tsx-vt::view-transition-new(root){animation:none;mix-blend-mode:normal}
:root.tsx-vt::view-transition-old(root){z-index:1}:root.tsx-vt::view-transition-new(root){z-index:2}`);
  const key = o.storageKey || 'theme', def = o.default || 'system', root = document.documentElement, mql = matchMedia('(prefers-color-scheme: dark)');
  const get = () => { try { return localStorage.getItem(key) || def; } catch { return def; } };
  const save = (v) => { try { localStorage.setItem(key, v); } catch {} };
  const resolve = (p) => (p === 'system' ? (mql.matches ? 'dark' : 'light') : p);
  const names = ['system', 'light', 'dark'];
  el.innerHTML = `<div class="tsx" role="group" aria-label="Theme"><i></i>${names.map((n) => `<button type="button" data-t="${n}" aria-label="${LABEL[n]}" title="${LABEL[n]}"><svg viewBox="0 0 24 24">${ICONS[n]}</svg></button>`).join('')}</div>`;
  const seg = el.firstElementChild, thumb = seg.querySelector('i'), btns = [...seg.querySelectorAll('button')];
  const apply = () => {
    const p = get(); root.dataset.pref = p; root.dataset.theme = resolve(p);
    btns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.t === p)));
    thumb.style.transform = `translateX(${names.indexOf(p) * 36}px)`;
  };
  const choose = (p, btn) => {
    const change = () => { save(p); apply(); };
    if (o.reveal === false || resolve(p) === root.dataset.theme || !document.startViewTransition || (!o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches)) return change();
    const b = btn.getBoundingClientRect(), x = b.left + b.width / 2, y = b.top + b.height / 2, r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
    root.classList.add('tsx-vt');
    const vt = document.startViewTransition(change);
    vt.ready.then(() => root.animate({ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] }, { duration: 900, easing: 'cubic-bezier(.65,.05,.25,1)', pseudoElement: '::view-transition-new(root)' })).catch(() => {});
    vt.finished.finally(() => root.classList.remove('tsx-vt'));
  };
  const click = (e) => { const b = e.target.closest('button[data-t]'); if (b) choose(b.dataset.t, b); };
  const sys = () => { if (get() === 'system') apply(); };
  seg.addEventListener('click', click); mql.addEventListener('change', sys);
  apply();
  return () => { seg.removeEventListener('click', click); mql.removeEventListener('change', sys); el.innerHTML = ''; };
}
```

## Framework wrappers

### React: `ThemeSwitcher.jsx`

```jsx
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountThemeSwitcher } from './theme-switcher.js';

export default function ThemeSwitcher({ options = {}, className, style }) {
  const ref = useRef(null);
  useEffect(() => mountThemeSwitcher(ref.current, options), []);   // the engine returns its own cleanup
  return <div ref={ref} className={className} style={style} />;
}
```

### Vue: `ThemeSwitcher.vue`

```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountThemeSwitcher } from './theme-switcher.js';

const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountThemeSwitcher(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>

<template><div ref="root"></div></template>
```

### Svelte: `ThemeSwitcher.svelte`

```svelte
<script>
  import { onMount } from 'svelte';
  import { mountThemeSwitcher } from './theme-switcher.js';

  export let options = {};
  export let style = '';
  let klass = '';
  export { klass as class };
  let root;
  onMount(() => mountThemeSwitcher(root, options));   // returning the cleanup from onMount stops it on destroy
</script>

<div bind:this={root} class={klass} {style}></div>
```

### Solid: `ThemeSwitcher.jsx`

```jsx
import { onMount, onCleanup } from 'solid-js';
import { mountThemeSwitcher } from './theme-switcher.js';

export default function ThemeSwitcher(props) {
  let root;
  onMount(() => onCleanup(mountThemeSwitcher(root, props.options || {})));
  return <div ref={root} class={props.class} style={props.style} />;
}
```
