← Playground

Theme switcher

System, day or night with a circle reveal

Hand it to your AI assistant

A ready-made skill: a Markdown file with instructions, the options, the full source and the wrappers for every framework. Save it as SKILL.md inside a folder named nikuma-theme-switcher in your assistant's skills folder (for Claude Code: .claude/skills/), or paste it into any chat, then say "add system / day / night with a circle reveal to my site".

Download SKILL.md

SKILL.mdpreview
---
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} />;
}
```

Get the code

One small file does all the work and has no dependencies. React, Vue, Svelte and Solid only add a few lines around it. Pick your tool below.

1. Copy the engine (same for every framework)

theme-switcher.js5 KB
// 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 = ''; };
}

Save it in your project (for example src/theme-switcher/). You never need to edit it: everything is an option.

2. Use it in your project

Plain HTML, Astro, Eleventy, WordPress, anything. No build step needed: a `<script type="module">` is enough.

Use it:

<div id="stage"></div>

<script type="module">
  import { mountThemeSwitcher } from './theme-switcher.js';

  const stop = mountThemeSwitcher(document.getElementById('stage'), { storageKey: 'theme' });
  // when you are done (for example when a page closes): stop();
</script>

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.

How it works

Options

Every option is optional. These are the defaults.

OptionDefaultWhat it does
storageKey'theme'where the choice is saved
default'system'‘system’
revealtruefalse = switch instantly, no circle

Tips

Nature does not hurry, yet everything is accomplished.
Lao Tzu