08テーマ保存 — 保存された値を読んで初期テーマを決めるトグル
テーマが変わった状態になるとその値が保存され、ページを再び開いた状態になると保存された値を読み込み、画面が描画される前に初期テーマをその値に合わせておきます。
使いどころダークモード切り替えスイッチ、複数ページをまたぐサービス
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">太陽</span>
<span class="tp__ic tp__ic--moon">月</span>
</button>
<div class="tp__card" id="tpCard" data-theme="light">
<p class="tp__label">保存されたテーマ</p>
<p class="tp__value" id="tpValue">ライト</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 ? 'ダーク' : 'ライト';
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 テーマ保存 — 保存された値を読んで初期テーマを決めるトグル — 計測 累積面積 6.717% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · マイクロインタラクション UIパターン9選 — トーストからスクロールスパイまで
このデモが属する記事
マイクロインタラクション UIパターン9選 — トーストからスクロールスパイまでマイクロインタラクションは、9つのうち1つは通知が積まれた順番の値から消える時点を求め直し後ろの通知の位置を詰め、1つはスクロールした位置で各見出しの重なり比率の値を比べて活性項目を変え、もう1つは入力の値が変わるたびに待機時間の値を最初か この記事のデモをすべて見る


