デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
太陽は、塗った円の芯と、線端の丸い 8 本の光線で描きます。押すと、光線が回りながら芯の中へしまわれ、膨らんだ芯を欠けの円が枠の外から滑り込んで削り、三日月になります。同時にページ全体が暗くなり、もう一度押すと、同じ動きを逆にたどって太陽と明るいページに戻ります。
光線は 0.4 秒で 90° 回しながら 0.4 倍に縮めて消します。芯は半径 4.5 から 8 へ、欠けの円は右上の外から中へ、どちらも 0.1 秒遅れて 0.55 秒の power3.inOut で動かします。形そのものが変わるので、この 2 つは transform ではなく attr で半径と中心の座標を補間します。ページの色は 0.65 秒で入れ替えます。
欠けは、SVG の mask の中に置いた黒い円です。mask の中の図形は、外側の svg に書いた線の指定を受け継ぐので、そのままだと欠けの縁に線幅ぶんの半透明の輪ができて、月の内側がにじみます。mask の中の図形は stroke を「none」にしておきます。
ページの色は、ルートに置いた CSS 変数(地・面・文字・補足・罫線の 5 色)を 1 本のトゥイーンでまとめて補間し、要素はすべて var() で塗ります。GSAP は var() のままでは補間できないので、明るい側と暗い側の値を CSS から読んで渡します。
最初のテーマは、保存した選択か OS の配色の設定で決め、動かさずに合わせます。状態は aria-pressed で伝え、ボタンに重ねると丸の外に薄い輪を出します。欠けの位置と大きさ、光線を回す角度で印象が変わります。ヘッダーのテーマ切り替えや、設定画面の見た目の切り替えに向いています。
コード
<!-- その場で切り替わるので button。状態は aria-pressed、名前は aria-label が持つ -->
<button class="theme-toggle" type="button" aria-pressed="false" aria-label="ダークモード">
<svg class="theme-toggle__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<defs>
<!-- 欠けの円を枠の外から滑り込ませて、芯を三日月の形に削る -->
<mask id="theme-toggle-bite" maskUnits="userSpaceOnUse" x="-8" y="-8" width="40" height="40">
<!-- mask の中の図形も svg の stroke を受け継ぐ。none にしないと、欠けの縁がにじむ -->
<rect x="-8" y="-8" width="40" height="40" fill="#fff" stroke="none" />
<circle class="theme-toggle__bite" cx="28" cy="-4" r="7.5" fill="#000" stroke="none" />
</mask>
</defs>
<path class="theme-toggle__rays" d="M19.6 12h2M17.37 17.37l1.42 1.42M12 19.6v2M6.63 17.37l-1.42 1.42M4.4 12h-2M6.63 6.63l-1.42-1.42M12 4.4v-2M17.37 6.63l1.42-1.42" />
<circle class="theme-toggle__core" cx="12" cy="12" r="4.5" mask="url(#theme-toggle-bite)" />
</svg>
</button>
/* テーマごとの色。JS がこの値を読み、--bg などをまとめて補間する */
:root {
--light-bg: #f2f1ed;
--light-surface: #ffffff;
--light-ink: #262626;
--light-mute: #6b6b6b;
--light-line: rgba(38, 38, 38, 0.12);
--dark-bg: #111111;
--dark-surface: #262626;
--dark-ink: #eeece6;
--dark-mute: #a8a6a0;
--dark-line: rgba(238, 236, 230, 0.14);
--bg: var(--light-bg);
--surface: var(--light-surface);
--ink: var(--light-ink);
--mute: var(--light-mute);
--line: var(--light-line);
}
/* head で先に data-theme を付けておけば、JS が動く前から暗い色で出る(ちらつかない) */
:root[data-theme="dark"] {
--bg: var(--dark-bg);
--surface: var(--dark-surface);
--ink: var(--dark-ink);
--mute: var(--dark-mute);
--line: var(--dark-line);
}
/* ページの要素は、すべて var() で塗る */
body {
background-color: var(--bg);
color: var(--ink);
}
.theme-toggle {
--halo: rgba(128, 128, 128, 0.2); /* 重ねたときの輪の色(どちらのテーマでも見える灰) */
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid var(--ink);
border-radius: 50%;
background: none;
color: var(--ink);
cursor: pointer;
transition: box-shadow 0.2s ease-out;
-webkit-appearance: none;
appearance: none;
}
/* ぼかしの無い影で、丸の外に薄い輪を出す */
.theme-toggle:hover {
box-shadow: 0 0 0 6px var(--halo);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
/* 24px で描いて線は 2px。線端と角は丸 */
.theme-toggle__icon {
width: 24px;
height: 24px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.theme-toggle__core {
fill: currentColor;
stroke: none;
}
@media (prefers-reduced-motion: reduce) {
.theme-toggle {
transition: none;
}
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const NAMES = ["bg", "surface", "ink", "mute", "line"]; // テーマで入れ替える色(CSS の --light-◯◯ と --dark-◯◯)
const page = document.documentElement;
const styles = getComputedStyle(page);
// GSAP は var(--name) のままでは補間できないので、値にして渡す
const colors = (theme) =>
Object.fromEntries(NAMES.map((name) => [`--${name}`, styles.getPropertyValue(`--${theme}-${name}`).trim()]));
// ページの色と、すべての切り替えボタンのアイコンを 1 本のタイムラインで動かす。暗くするのも戻すのも play / reverse
const theme = gsap
.timeline({ paused: true })
.fromTo(page, colors("light"), { ...colors("dark"), duration: 0.65, ease: "power2.inOut" }, 0);
const buttons = document.querySelectorAll(".theme-toggle");
buttons.forEach((button, i) => {
const rays = button.querySelector(".theme-toggle__rays");
const core = button.querySelector(".theme-toggle__core");
const bite = button.querySelector(".theme-toggle__bite");
// ページに 2 つ置いても mask の id がぶつからないよう、番号を付け直す
const mask = button.querySelector("mask");
mask.id = `${mask.id}-${i}`;
core.setAttribute("mask", `url(#${mask.id})`);
theme
// 光線は回りながら芯へしまわれる(回す中心は viewBox の中央)
.fromTo(rays, { scale: 1, rotation: 0, opacity: 1, svgOrigin: "12 12" }, { scale: 0.4, rotation: 90, opacity: 0, svgOrigin: "12 12", duration: 0.4, ease: "power2.in" }, 0)
// 芯が膨らみ、欠けの円が枠の外から滑り込んで削る。形そのものを変えるので、属性を補間する
.fromTo(core, { attr: { r: 4.5 } }, { attr: { r: 8 }, duration: 0.55, ease: "power3.inOut" }, 0.1)
.fromTo(bite, { attr: { cx: 28, cy: -4 } }, { attr: { cx: 16.5, cy: 7.5 }, duration: 0.55, ease: "power3.inOut" }, 0.1);
});
// 動きを減らす設定では、ほぼ一瞬で切り替える
if (reduceMotion) theme.timeScale(20);
// 最初のテーマは、保存した選択か OS の設定で決め、動かさずに合わせる
const saved = localStorage.getItem("theme");
let dark = saved ? saved === "dark" : window.matchMedia("(prefers-color-scheme: dark)").matches;
theme.progress(dark ? 1 : 0);
const apply = () => {
page.dataset.theme = dark ? "dark" : "light";
buttons.forEach((button) => button.setAttribute("aria-pressed", String(dark)));
};
apply();
buttons.forEach((button) =>
button.addEventListener("click", () => {
dark = !dark;
dark ? theme.play() : theme.reverse();
apply();
localStorage.setItem("theme", dark ? "dark" : "light");
})
);