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".
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>Works in Vite, Create React App, Next.js (App Router needs the `'use client'` line, already included), Remix. The engine only touches the page inside `useEffect`, so server rendering is safe.
Create ThemeSwitcher.jsx next to the engine:
'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} />;
}Then use it:
import ThemeSwitcher from './ThemeSwitcher';
export default function Page() {
return <ThemeSwitcher options={{ storageKey: 'theme' }} />;
}Works in Vite + Vue 3 and Nuxt (wrap it in `<ClientOnly>` if you want to be extra safe; it only touches the page in `onMounted`).
Create ThemeSwitcher.vue next to the engine:
<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>Then use it:
<script setup>
import ThemeSwitcher from './ThemeSwitcher.vue';
</script>
<template>
<ThemeSwitcher :options="{ storageKey: 'theme' }" />
</template>Works in Svelte 4 and 5 (the `export let` style still works in 5) and SvelteKit. Starts in `onMount`, so it never runs on the server.
Create ThemeSwitcher.svelte next to the engine:
<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>Then use it:
<script>
import ThemeSwitcher from './ThemeSwitcher.svelte';
</script>
<ThemeSwitcher options={{ storageKey: 'theme' }} />Works in Solid and SolidStart. Starts in `onMount`, so it never runs on the server.
Create ThemeSwitcher.jsx next to the engine:
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} />;
}Then use it:
import ThemeSwitcher from './ThemeSwitcher';
export default function Page() {
return <ThemeSwitcher options={{ storageKey: 'theme' }} />;
}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
- It only sets
<html data-theme="light|dark">anddata-pref. You style your colours with CSS variables for each theme, nothing else is needed. - The choice is saved in
localStorage(inside try/catch, so private windows work). “System” follows the OS and updates live when the OS switches. - The circle uses the View Transitions API (
document.startViewTransition) and animates the clip-path of the new page. Browsers without it simply switch instantly. - No circle for visitors who prefer reduced motion.
Options
Every option is optional. These are the defaults.
| Option | Default | What it does |
|---|---|---|
storageKey | 'theme' | where the choice is saved |
default | 'system' | ‘system’ |
reveal | true | false = switch instantly, no circle |
Tips
- Add the tiny head script from the top of
theme-switcher.jsso the right theme is set before the page paints (no flash). - CSS:
:root{--bg:#fff;--fg:#111}and:root[data-theme="dark"]{--bg:#0d0d0e;--fg:#ededee}. - Tailwind: set
darkMode: ["selector", '[data-theme="dark"]'].