08Theme persistence — a toggle deciding its initial theme from a stored value
Once the theme changes, that value is stored, and once the page is opened again, the stored value is read and the initial theme is set to it before the page is painted.
Best forA dark-mode switch, a service spanning multiple pages
localStoragegetBoundingClientRect--x

<main class="stage">
<div class="tp is-demo" id="tp">
<button class="tp__track" id="tpTrack" type="button" aria-pressed="false">
<span class="tp__knob" id="tpKnob"></span>
<span class="tp__ic tp__ic--sun">Sun</span>
<span class="tp__ic tp__ic--moon">Moon</span>
</button>
<div class="tp__card" id="tpCard" data-theme="light">
<p class="tp__label">Saved theme</p>
<p class="tp__value" id="tpValue">Light</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 테마 저장 토글 — 놉 이동 거리(--x)는 트랙·놉의 실측 사각형 차이에서 구한 값이다.
// 카드 색은 data-theme 속성 값에 따라 바뀌고 localStorage 에도 같은 값이 남는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-paper);
.tp {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-5;
}
.tp__track {
position: relative;
width: 76px;
height: 36px;
border: 0;
border-radius: $r-pill;
background: $subject-lilac;
cursor: pointer;
padding: 0;
}
.tp__knob {
position: absolute;
top: 3px;
left: 3px;
width: 30px;
height: 30px;
border-radius: 50%;
background: $stage-paper;
transform: translateX(var(--x, 0));
transition: transform $duration $easing;
}
.tp__ic {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 700;
color: $subject-ink;
}
.tp__ic--sun { left: $sp-2; }
.tp__ic--moon { right: $sp-2; }
.tp__card {
width: min(100%, 200px);
padding: $sp-4;
border-radius: $r-card;
text-align: center;
background: $color;
transition: background $duration $easing;
}
.tp__card[data-theme="dark"] {
background: $subject-ink;
}
.tp__label {
margin: 0 0 $sp-1;
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, .6);
}
.tp__card[data-theme="dark"] .tp__label { color: rgba(255, 255, 255, .6); }
.tp__value {
margin: 0;
font-size: 14px;
font-weight: 800;
color: $subject-ink;
}
.tp__card[data-theme="dark"] .tp__value { color: $stage-paper; }
@media (prefers-reduced-motion: reduce) {
.tp__knob, .tp__card { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tp {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.tp__track {
position: relative;
width: 76px;
height: 36px;
border: 0;
border-radius: 999px;
background: #b9a0ff;
cursor: pointer;
padding: 0;
}
.tp__knob {
position: absolute;
top: 3px;
left: 3px;
width: 30px;
height: 30px;
border-radius: 50%;
background: #ffffff;
transform: translateX(var(--x, 0));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tp__ic {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 700;
color: #17141a;
}
.tp__ic--sun {
left: 8px;
}
.tp__ic--moon {
right: 8px;
}
.tp__card {
width: min(100%, 200px);
padding: 16px;
border-radius: 16px;
text-align: center;
background: #ff9ebb;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tp__card[data-theme=dark] {
background: #17141a;
}
.tp__label {
margin: 0 0 4px;
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.6);
}
.tp__card[data-theme=dark] .tp__label {
color: rgba(255, 255, 255, 0.6);
}
.tp__value {
margin: 0;
font-size: 14px;
font-weight: 800;
color: #17141a;
}
.tp__card[data-theme=dark] .tp__value {
color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
.tp__knob, .tp__card {
transition: none !important;
}
}(function () {
var root = document.getElementById('tp');
var track = document.getElementById('tpTrack');
var knob = document.getElementById('tpKnob');
var card = document.getElementById('tpCard');
var valueEl = document.getElementById('tpValue');
var KEY = 'demo-348-theme';
var dark = false;
var autoplay = null;
// 계산 겹: 트랙·놉의 실측 사각형에서 이동 거리를 다시 구해 --x 로 옮긴다(고정 % 아님).
// 값을 켤 때마다 localStorage 에 적어 다음 로드 때 같은 값을 복원할 수 있게 한다.
function place() {
var trackRect = track.getBoundingClientRect();
var knobRect = knob.getBoundingClientRect();
var travel = trackRect.width - knobRect.width - 6;
knob.style.setProperty('--x', (dark ? travel : 0).toFixed(1) + 'px');
}
function apply(next, persist) {
dark = next;
track.setAttribute('aria-pressed', String(dark));
card.setAttribute('data-theme', dark ? 'dark' : 'light');
valueEl.textContent = dark ? 'Dark' : 'Light';
place();
if (persist) {
try { window.localStorage.setItem(KEY, dark ? 'dark' : 'light'); } catch (err) { /* 무시 */ }
}
}
try {
var saved = window.localStorage.getItem(KEY);
if (saved === 'dark') { apply(true, false); } else { apply(false, false); }
} catch (err) { apply(false, false); }
track.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
apply(!dark, true);
});
window.addEventListener('resize', place);
function stopAutoplay() { if (autoplay) { clearInterval(autoplay); autoplay = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplay = setInterval(function () { apply(!dark, true); }, 620);
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./ThemePersist.module.scss";
// 테마 저장 토글 — 놉 이동 거리(--x)는 트랙·놉의 실측 사각형 차이에서 구한 값이다(BD19).
// 계산 겹: 값을 켤 때마다 localStorage 에 적어 다음 로드 때 같은 값을 복원한다.
const KEY = "demo-348-theme";
export default function ThemePersist() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLButtonElement>(null);
const knobRef = useRef<HTMLSpanElement>(null);
const [dark, setDark] = useState(false);
const darkRef = useRef(false);
const autoplayRef = useRef<number | null>(null);
function place(nextDark: boolean) {
const track = trackRef.current;
const knob = knobRef.current;
if (!track || !knob) return;
const trackRect = track.getBoundingClientRect();
const knobRect = knob.getBoundingClientRect();
const travel = trackRect.width - knobRect.width - 6;
knob.style.setProperty("--x", (nextDark ? travel : 0).toFixed(1) + "px");
}
function apply(next: boolean, persist: boolean) {
darkRef.current = next;
setDark(next);
place(next);
if (persist) {
try {
window.localStorage.setItem(KEY, next ? "dark" : "light");
} catch (err) { /* 무시 */ }
}
}
function stopAutoplay() {
if (autoplayRef.current) {
window.clearInterval(autoplayRef.current);
autoplayRef.current = null;
}
}
useEffect(() => {
let initial = false;
try {
initial = window.localStorage.getItem(KEY) === "dark";
} catch (err) { initial = false; }
apply(initial, false);
const onResize = () => place(darkRef.current ?? false);
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayRef.current = window.setInterval(() => {
apply(!(darkRef.current ?? false), true);
}, 620);
}
return () => {
stopAutoplay();
window.removeEventListener("resize", onResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.tp} ${styles["is-demo"]}`}>
<button
ref={trackRef}
className={styles.tp__track}
type="button"
aria-pressed={dark}
onClick={() => {
rootRef.current?.classList.remove(styles["is-demo"]);
stopAutoplay();
apply(!dark, true);
}}
>
<span ref={knobRef} className={styles.tp__knob} />
<span className={`${styles.tp__ic} ${styles["tp__ic--sun"]}`}></span>
<span className={`${styles.tp__ic} ${styles["tp__ic--moon"]}`}></span>
</button>
<div className={styles.tp__card} data-theme={dark ? "dark" : "light"}>
<p className={styles.tp__label}>저장된 테마</p>
<p className={styles.tp__value}>{dark ? "어둡게" : "밝게"}</p>
</div>
</div>
</main>
);
}08 Theme persistence — a toggle deciding its initial theme from a stored value — Measured area 6.717% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · Micro Interactions: 9 Essential UI Patterns
The post this demo lives in
Micro Interactions: 9 Essential UI PatternsMicro interactions: One toast recomputes its dismiss time from its stack order; one compares each heading's viewport ove See all demos in this post
If you like this effect - similar demos
Interactions07 Saved ratio presets9 Resizable Split Pane Layouts With No Library
Interactions06 Nav cursor effect — a sliding pillMouse Follow Effect CSS — 9 Cursor Chasers
Interactions03 Fallback selection — a card that leaves a manual path when the fast one fails9 Copy Button CSS UI Patterns (Copy to Clipboard)