9 CSS Dark Mode Toggle Animations, One Line Each
A css dark mode toggle flips a site's color scheme when it's switched, usually by toggling one class or custom property.
01 dark-mode-toggle-sun-moon
02 dark-mode-toggle-slide-knob
03 dark-mode-toggle-circle-expand
04 dark-mode-toggle-light-dark-fn
05 dark-mode-toggle-eclipse-mask
06 dark-mode-toggle-flip-card
08 dark-mode-toggle-color-fade
09 dark-mode-toggle-three-state
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Sun/moon icon swap
- 02 Sliding knob
- 03 Circular reveal via View Transitions
- 04 One-flip light and dark swap
- 05 Eclipse mask transition
- 06 Flip-card transition
- 07 Icon morph transition
- 08 Whole-page color fade
- 09 Light/dark/auto 3-state
The order runs from the most familiar shape to the least. 01, 02, 06, and 07 are variations on a physical switch — an icon, a knob, a card, a set of rays — the kind of control a reader already expects to find in a header. 03 and 05 spend that familiarity on a bigger reveal, sweeping the whole panel into its new scheme instead of just flipping one small shape. 04 sets the icon aside entirely and shows the one CSS value doing the real work underneath any of the eight others, and 08 and 09 close it out — one page-wide fade, one switch with a third option beyond just light and dark.
01Sun/moon icon swap
What it does
A sun icon rotates and fades out while a moon icon fades in behind it at the same time, the two shapes cross-fading rather than one path morphing into the other.

<main class="stage">
<div class="panel">
<div class="panel__face panel__face--light">
<p class="panel__title">Settings</p>
<p class="panel__line"></p>
</div>
<div class="panel__face panel__face--dark">
<p class="panel__title">Settings</p>
<p class="panel__line"></p>
</div>
<button class="toggle" type="button" aria-label="Toggle dark mode">
<span class="toggle__sun"></span>
<span class="toggle__moon"></span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.panel {
position: relative; overflow: hidden;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
}
.panel__face {
position: absolute; inset: 0; box-sizing: border-box;
padding: clamp(12px, 5vw, 18px); text-align: left;
}
.panel__face--light {
background: $subject-cream; color: $subject-ink;
opacity: 1; animation: face-light $dur-loop $easing infinite;
}
.panel__face--dark {
background: $stage-ink; color: $subject-cream;
opacity: 0; animation: face-dark $dur-loop $easing infinite;
}
.panel__title { margin: 0 0 6px; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
.panel__line { margin: 0; width: 60%; height: 8px; border-radius: 4px; background: currentColor; opacity: .25; }
.toggle {
position: absolute; top: clamp(8px, 3vw, 12px); right: clamp(8px, 3vw, 12px);
width: 28px; height: 28px; border: 0; border-radius: 50%; padding: 0;
background: rgba(255, 255, 255, .14); cursor: pointer;
}
.toggle__sun, .toggle__moon {
position: absolute; inset: 5px; border-radius: 50%; transform-origin: center;
}
.toggle__sun {
background: $stage-yellow; opacity: 1; transform: rotate(0deg);
animation: sun $dur-loop $easing infinite;
}
.toggle__moon {
background: $subject-cream; opacity: 0; transform: rotate(-90deg);
animation: moon $dur-loop $easing infinite;
}
// 0~15% 라이트 유지 · 15~40% 전환 · 40~60% 다크 유지 · 60~85% 전환 · 85~100% 라이트로 되돌아감
@keyframes face-light { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes face-dark { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
@keyframes sun { 0%, 15% { opacity: 1; transform: rotate(0deg); } 40%, 60% { opacity: 0; transform: rotate(90deg); } 85%, 100% { opacity: 1; transform: rotate(0deg); } }
@keyframes moon { 0%, 15% { opacity: 0; transform: rotate(-90deg); } 40%, 60% { opacity: 1; transform: rotate(0deg); } 85%, 100% { opacity: 0; transform: rotate(-90deg); } }
// ── 접근성: 움직임 줄이기 설정이면 라이트 상태로 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .toggle__sun, .toggle__moon { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
position: relative;
overflow: hidden;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.panel__face {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
text-align: left;
}
.panel__face--light {
background: #fff7e6;
color: #17141a;
opacity: 1;
animation: face-light 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel__face--dark {
background: #17141a;
color: #fff7e6;
opacity: 0;
animation: face-dark 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel__title {
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.panel__line {
margin: 0;
width: 60%;
height: 8px;
border-radius: 4px;
background: currentColor;
opacity: 0.25;
}
.toggle {
position: absolute;
top: clamp(8px, 3vw, 12px);
right: clamp(8px, 3vw, 12px);
width: 28px;
height: 28px;
border: 0;
border-radius: 50%;
padding: 0;
background: rgba(255, 255, 255, 0.14);
cursor: pointer;
}
.toggle__sun, .toggle__moon {
position: absolute;
inset: 5px;
border-radius: 50%;
transform-origin: center;
}
.toggle__sun {
background: #ffd23f;
opacity: 1;
transform: rotate(0deg);
animation: sun 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.toggle__moon {
background: #fff7e6;
opacity: 0;
transform: rotate(-90deg);
animation: moon 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes face-light {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes face-dark {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes sun {
0%, 15% {
opacity: 1;
transform: rotate(0deg);
}
40%, 60% {
opacity: 0;
transform: rotate(90deg);
}
85%, 100% {
opacity: 1;
transform: rotate(0deg);
}
}
@keyframes moon {
0%, 15% {
opacity: 0;
transform: rotate(-90deg);
}
40%, 60% {
opacity: 1;
transform: rotate(0deg);
}
85%, 100% {
opacity: 0;
transform: rotate(-90deg);
}
}
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .toggle__sun, .toggle__moon {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./SunMoonToggle.module.scss";
export type SunMoonToggleProps = {
/** 다크모드가 켜져 있는가 */
dark: boolean;
onToggle?: () => void;
/** 전환 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SunMoonToggle({
dark,
onToggle,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
className = "",
}: SunMoonToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button
type="button"
className={`${styles.toggle} ${className}`}
style={vars}
data-dark={dark}
aria-label="다크모드 전환"
onClick={onToggle}
>
<span className={styles.sun} />
<span className={styles.moon} />
</button>
);
}01 · Sun/moon icon swap — Measured area 26.961% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It's the toggle shape most readers already recognize from browser chrome and OS settings, which is why it reads instantly in a header or a settings menu.
.toggle__sun {
background: $stage-yellow;
transform: rotate(0deg);
animation: sun $duration $easing infinite;
}
.toggle__moon {
background: $subject-cream;
opacity: 0;
transform: rotate(-90deg);
animation: moon $duration $easing infinite;
}
@keyframes sun {
0% { opacity: 1; transform: rotate(0deg); }
50% { opacity: 0; transform: rotate(90deg); }
100% { opacity: 1; transform: rotate(0deg); }
}
02Sliding knob
What you see
A round knob slides across a pill-shaped track while the track's own background color shifts underneath it, so the switch reads as one continuous move instead of two separate changes happening at once.

<main class="stage">
<div class="panel">
<div class="panel__face panel__face--light">
<p class="panel__title">Notifications</p>
<p class="panel__line"></p>
</div>
<div class="panel__face panel__face--dark">
<p class="panel__title">Notifications</p>
<p class="panel__line"></p>
</div>
<span class="track">
<span class="track__bg track__bg--light"></span>
<span class="track__bg track__bg--dark"></span>
<span class="knob"></span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.panel {
position: relative; overflow: hidden;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
}
.panel__face {
position: absolute; inset: 0; box-sizing: border-box;
padding: clamp(12px, 5vw, 18px); text-align: left;
}
.panel__face--light {
background: $subject-cream; color: $subject-ink;
opacity: 1; animation: face-light $dur-loop $easing infinite;
}
.panel__face--dark {
background: $stage-ink; color: $subject-cream;
opacity: 0; animation: face-dark $dur-loop $easing infinite;
}
.panel__title { margin: 0 0 6px; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
.panel__line { margin: 0; width: 55%; height: 8px; border-radius: 4px; background: currentColor; opacity: .25; }
.track {
position: absolute; top: clamp(8px, 3vw, 12px); right: clamp(8px, 3vw, 12px);
display: block; width: 44px; height: 24px; border-radius: $r-pill; overflow: hidden;
}
.track__bg { position: absolute; inset: 0; }
.track__bg--light { background: rgba(255, 255, 255, .5); opacity: 1; animation: face-light $dur-loop $easing infinite; }
.track__bg--dark { background: $subject-ink; opacity: 0; animation: face-dark $dur-loop $easing infinite; }
.knob {
position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
background: $color; box-shadow: $shadow-press;
transform: translateX(0); animation: knob-slide $dur-loop $easing infinite;
}
// 0~15% 라이트 유지 · 15~40% 전환 · 40~60% 다크 유지 · 60~85% 전환 · 85~100% 라이트로 되돌아감
@keyframes face-light { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes face-dark { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
@keyframes knob-slide {
0%, 15% { transform: translateX(0); }
40%, 60% { transform: translateX(20px); }
85%, 100% { transform: translateX(0); }
}
// ── 접근성: 움직임 줄이기 설정이면 라이트 상태로 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .track__bg--light, .track__bg--dark, .knob { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
position: relative;
overflow: hidden;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.panel__face {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
text-align: left;
}
.panel__face--light {
background: #fff7e6;
color: #17141a;
opacity: 1;
animation: face-light 2s ease-out infinite;
}
.panel__face--dark {
background: #17141a;
color: #fff7e6;
opacity: 0;
animation: face-dark 2s ease-out infinite;
}
.panel__title {
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.panel__line {
margin: 0;
width: 55%;
height: 8px;
border-radius: 4px;
background: currentColor;
opacity: 0.25;
}
.track {
position: absolute;
top: clamp(8px, 3vw, 12px);
right: clamp(8px, 3vw, 12px);
display: block;
width: 44px;
height: 24px;
border-radius: 999px;
overflow: hidden;
}
.track__bg {
position: absolute;
inset: 0;
}
.track__bg--light {
background: rgba(255, 255, 255, 0.5);
opacity: 1;
animation: face-light 2s ease-out infinite;
}
.track__bg--dark {
background: #17141a;
opacity: 0;
animation: face-dark 2s ease-out infinite;
}
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
animation: knob-slide 2s ease-out infinite;
}
@keyframes face-light {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes face-dark {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes knob-slide {
0%, 15% {
transform: translateX(0);
}
40%, 60% {
transform: translateX(20px);
}
85%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .track__bg--light, .track__bg--dark, .knob {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./SlideKnobToggle.module.scss";
export type SlideKnobToggleProps = {
dark: boolean;
onToggle?: () => void;
/** 노브가 미끄러지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SlideKnobToggle({
dark,
onToggle,
duration = 600,
easing = "ease-out",
color = "#2f6df6",
className = "",
}: SlideKnobToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button
type="button"
className={`${styles.track} ${className}`}
style={vars}
data-dark={dark}
aria-label="다크모드 전환"
onClick={onToggle}
>
<span className={styles.knob} />
</button>
);
}02 · Sliding knob — Measured area 26.944% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It suits a settings screen or the right edge of a nav bar — anywhere a toggle switch is already the shape a reader expects.
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: $color;
transform: translateX(0);
animation: knob-slide $duration $easing infinite;
}
@keyframes knob-slide {
0% { transform: translateX(0); }
50% { transform: translateX(20px); }
100% { transform: translateX(0); }
}
03Circular reveal via View Transitions
The motion
A circle grows outward from the toggle and sweeps the whole panel into its new color scheme in one motion, the same silhouette the View Transitions API draws when it clips a snapshot of the old page while the new one grows in behind it.

<main class="stage">
<div class="panel">
<p class="panel__title">Landing</p>
<p class="panel__line"></p>
<span class="wipe"></span>
<span class="origin"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter;
$easing: $ease-pop;
$color: $stage-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.panel {
position: relative; overflow: hidden;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
background: $subject-cream; color: $subject-ink;
box-sizing: border-box; padding: clamp(12px, 5vw, 18px); text-align: left;
}
.panel__title { position: relative; z-index: 1; margin: 0 0 6px; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
.panel__line { position: relative; z-index: 1; margin: 0; width: 55%; height: 8px; border-radius: 4px; background: currentColor; opacity: .25; }
// 클릭 지점(우상단 토글 자리)에서 원이 퍼져 화면을 다크모드로 덮는다. transform: scale 하나로.
.wipe {
position: absolute; z-index: 0; top: -60px; right: -60px;
width: 320px; height: 320px; border-radius: 50%;
background: $color; transform-origin: 78% 22%; // 규칙8: 클릭 지점서 퍼짐
transform: scale(0); animation: wipe-scale $dur-loop $easing infinite;
}
.origin {
position: absolute; z-index: 2; top: clamp(8px, 3vw, 12px); right: clamp(8px, 3vw, 12px);
width: 14px; height: 14px; border-radius: 50%; background: $stage-yellow;
animation: origin-pulse $dur-loop $easing infinite;
}
@keyframes wipe-scale {
0%, 10% { transform: scale(0); }
45%, 55% { transform: scale(1); }
90%, 100% { transform: scale(0); }
}
@keyframes origin-pulse {
0%, 10% { opacity: 1; transform: scale(1); }
45%, 55% { opacity: .4; transform: scale(1.3); }
90%, 100% { opacity: 1; transform: scale(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 원이 퍼지지 않는다 ──────────
@media (prefers-reduced-motion: reduce) {
.wipe, .origin { animation: none !important; transform: scale(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
position: relative;
overflow: hidden;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
text-align: left;
}
.panel__title {
position: relative;
z-index: 1;
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.panel__line {
position: relative;
z-index: 1;
margin: 0;
width: 55%;
height: 8px;
border-radius: 4px;
background: currentColor;
opacity: 0.25;
}
.wipe {
position: absolute;
z-index: 0;
top: -60px;
right: -60px;
width: 320px;
height: 320px;
border-radius: 50%;
background: #17141a;
transform-origin: 78% 22%;
transform: scale(0);
animation: wipe-scale 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.origin {
position: absolute;
z-index: 2;
top: clamp(8px, 3vw, 12px);
right: clamp(8px, 3vw, 12px);
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffd23f;
animation: origin-pulse 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes wipe-scale {
0%, 10% {
transform: scale(0);
}
45%, 55% {
transform: scale(1);
}
90%, 100% {
transform: scale(0);
}
}
@keyframes origin-pulse {
0%, 10% {
opacity: 1;
transform: scale(1);
}
45%, 55% {
opacity: 0.4;
transform: scale(1.3);
}
90%, 100% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.wipe, .origin {
animation: none !important;
transform: scale(0);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./CircleExpandToggle.module.scss";
export type CircleExpandToggleProps = {
dark: boolean;
onToggle?: () => void;
/** 원이 다 퍼지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 다크모드로 덮는 원의 색 */
color?: string;
children?: ReactNode;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CircleExpandToggle({
dark,
onToggle,
duration = 600,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
children,
className = "",
}: CircleExpandToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.panel} ${className}`} style={vars} data-dark={dark}>
<div className={styles.content}>{children}</div>
<span className={styles.wipe} />
<button type="button" className={styles.origin} aria-label="다크모드 전환" onClick={onToggle} />
</div>
);
}03 · Circular reveal via View Transitions — Measured area 26.663% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It earns its extra weight on a landing hero or an app's first screen, somewhere a single dramatic switch is worth noticing.
.wipe {
position: absolute;
top: -60px;
right: -60px;
width: 320px;
height: 320px;
border-radius: 50%;
background: $color;
transform-origin: 78% 22%;
transform: scale(0);
animation: wipe-scale $duration $easing infinite;
}
@keyframes wipe-scale {
0% { transform: scale(0); }
50% { transform: scale(1); }
100% { transform: scale(0); }
}
04One-flip light and dark swap
What it does
Two swatches swap places to stand in for what a single color-scheme declaration does site-wide: once it's set, the light-dark() function picks the right value for background, text, or a border in one line each, with nothing else to toggle by hand.

<main class="stage">
<div class="panel">
<div class="panel__face panel__face--light">
<p class="panel__title">Tokens</p>
<p class="panel__line"></p>
</div>
<div class="panel__face panel__face--dark">
<p class="panel__title">Tokens</p>
<p class="panel__line"></p>
</div>
<span class="chip">
<span class="chip__swatch chip__swatch--light"></span>
<span class="chip__swatch chip__swatch--dark"></span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.panel {
position: relative; overflow: hidden;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
}
.panel__face {
position: absolute; inset: 0; box-sizing: border-box;
padding: clamp(12px, 5vw, 18px); text-align: left;
}
.panel__face--light {
background: $subject-cream; color: $subject-ink;
opacity: 1; animation: face-light $dur-loop $easing infinite;
}
.panel__face--dark {
background: $stage-ink; color: $subject-cream;
opacity: 0; animation: face-dark $dur-loop $easing infinite;
}
.panel__title { margin: 0 0 6px; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
.panel__line { margin: 0; width: 55%; height: 8px; border-radius: 4px; background: currentColor; opacity: .25; }
// 변수 하나(color-scheme)만 바뀌면 스와치가 통째로 뒤집힌다는 걸 작은 칩으로 보여준다
.chip {
position: absolute; top: clamp(8px, 3vw, 12px); right: clamp(8px, 3vw, 12px);
width: 24px; height: 24px; border-radius: $r-control; overflow: hidden; box-shadow: $shadow-press;
}
.chip__swatch { position: absolute; inset: 0; }
.chip__swatch--light { background: $color; opacity: 1; animation: face-light $dur-loop $easing infinite; }
.chip__swatch--dark { background: $stage-yellow; opacity: 0; animation: face-dark $dur-loop $easing infinite; }
@keyframes face-light { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes face-dark { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
// ── 접근성: 움직임 줄이기 설정이면 라이트 상태로 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .chip__swatch--light, .chip__swatch--dark { animation: 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);
}
.panel {
position: relative;
overflow: hidden;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.panel__face {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
text-align: left;
}
.panel__face--light {
background: #fff7e6;
color: #17141a;
opacity: 1;
animation: face-light 2s ease-out infinite;
}
.panel__face--dark {
background: #17141a;
color: #fff7e6;
opacity: 0;
animation: face-dark 2s ease-out infinite;
}
.panel__title {
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.panel__line {
margin: 0;
width: 55%;
height: 8px;
border-radius: 4px;
background: currentColor;
opacity: 0.25;
}
.chip {
position: absolute;
top: clamp(8px, 3vw, 12px);
right: clamp(8px, 3vw, 12px);
width: 24px;
height: 24px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.chip__swatch {
position: absolute;
inset: 0;
}
.chip__swatch--light {
background: #2f6df6;
opacity: 1;
animation: face-light 2s ease-out infinite;
}
.chip__swatch--dark {
background: #ffd23f;
opacity: 0;
animation: face-dark 2s ease-out infinite;
}
@keyframes face-light {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes face-dark {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .chip__swatch--light, .chip__swatch--dark {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./LightDarkSwitch.module.scss";
export type LightDarkSwitchProps = {
/** true 면 color-scheme: dark 로 light-dark() 값이 뒤집힌다 */
dark: boolean;
onToggle?: () => void;
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LightDarkSwitch({
dark,
onToggle,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
className = "",
}: LightDarkSwitchProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button
type="button"
className={`${styles.chip} ${className}`}
style={vars}
data-dark={dark}
aria-label="다크모드 전환"
onClick={onToggle}
>
<span className={`${styles.swatch} ${styles.light}`} />
<span className={`${styles.swatch} ${styles.dark}`} />
</button>
);
}04 · One-flip light and dark swap — Measured area 26.944% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It's built for a component library or a design-system token file, where dozens of colors need to flip together and none of them should need their own media query.
.chip__swatch--light {
background: $color;
opacity: 1;
animation: face-light $duration $easing infinite;
}
.chip__swatch--dark {
background: $stage-yellow;
opacity: 0;
animation: face-dark $duration $easing infinite;
}
@keyframes face-light {
0%, 15% { opacity: 1; }
40%, 60% { opacity: 0; }
85%, 100% { opacity: 1; }
}
05Eclipse mask transition
What you see
A dark, moon-shaped disc slides in from the side and settles directly over a sun icon, the two shapes overlapping like an eclipse instead of one simply fading into the other.

<main class="stage">
<div class="panel">
<div class="panel__face panel__face--light">
<p class="panel__title">Profile</p>
<p class="panel__line"></p>
</div>
<div class="panel__face panel__face--dark">
<p class="panel__title">Profile</p>
<p class="panel__line"></p>
</div>
<span class="sun"></span>
<span class="mask"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $stage-yellow;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.panel {
position: relative; overflow: hidden;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
}
.panel__face {
position: absolute; inset: 0; box-sizing: border-box;
padding: clamp(12px, 5vw, 18px); text-align: left;
}
.panel__face--light {
background: $subject-cream; color: $subject-ink;
opacity: 1; animation: face-light $dur-loop $easing infinite;
}
.panel__face--dark {
background: $stage-ink; color: $subject-cream;
opacity: 0; animation: face-dark $dur-loop $easing infinite;
}
.panel__title { margin: 0 0 6px; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
.panel__line { margin: 0; width: 55%; height: 8px; border-radius: 4px; background: currentColor; opacity: .25; }
// 태양 아이콘을 달 모양 마스크가 옆에서 밀려와 가리며 다크모드로 넘어간다. transform: translateX 만 쓴다.
.sun {
position: absolute; z-index: 1; bottom: clamp(10px, 4vw, 16px); left: clamp(12px, 5vw, 18px);
width: 26px; height: 26px; border-radius: 50%; background: $color;
}
.mask {
position: absolute; z-index: 2; bottom: clamp(6px, 4vw, 12px); left: clamp(0px, 2vw, 4px);
width: 26px; height: 26px; border-radius: 50%; background: $stage-ink;
transform: translateX(-60px); animation: mask-slide $dur-loop $easing infinite;
}
@keyframes face-light { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes face-dark { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
@keyframes mask-slide {
0%, 15% { transform: translateX(-60px); }
40%, 60% { transform: translateX(4px); }
85%, 100% { transform: translateX(-60px); }
}
// ── 접근성: 움직임 줄이기 설정이면 라이트 상태로 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .mask { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
position: relative;
overflow: hidden;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.panel__face {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
text-align: left;
}
.panel__face--light {
background: #fff7e6;
color: #17141a;
opacity: 1;
animation: face-light 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel__face--dark {
background: #17141a;
color: #fff7e6;
opacity: 0;
animation: face-dark 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel__title {
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.panel__line {
margin: 0;
width: 55%;
height: 8px;
border-radius: 4px;
background: currentColor;
opacity: 0.25;
}
.sun {
position: absolute;
z-index: 1;
bottom: clamp(10px, 4vw, 16px);
left: clamp(12px, 5vw, 18px);
width: 26px;
height: 26px;
border-radius: 50%;
background: #ffd23f;
}
.mask {
position: absolute;
z-index: 2;
bottom: clamp(6px, 4vw, 12px);
left: clamp(0px, 2vw, 4px);
width: 26px;
height: 26px;
border-radius: 50%;
background: #17141a;
transform: translateX(-60px);
animation: mask-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes face-light {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes face-dark {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes mask-slide {
0%, 15% {
transform: translateX(-60px);
}
40%, 60% {
transform: translateX(4px);
}
85%, 100% {
transform: translateX(-60px);
}
}
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark, .mask {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./EclipseMaskToggle.module.scss";
export type EclipseMaskToggleProps = {
dark: boolean;
onToggle?: () => void;
/** 마스크가 밀려오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 마스크(다크) 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function EclipseMaskToggle({
dark,
onToggle,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
className = "",
}: EclipseMaskToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button
type="button"
className={`${styles.wrap} ${className}`}
style={vars}
data-dark={dark}
aria-label="다크모드 전환"
onClick={onToggle}
>
<span className={styles.sun} />
<span className={styles.mask} />
</button>
);
}05 · Eclipse mask transition — Measured area 26.797% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It's built for a toggle that wants to be noticed — a brand's own dark-mode button, or a corner of a product page introducing the feature for the first time.
.sun {
position: absolute;
bottom: 16px;
left: 18px;
width: 26px;
height: 26px;
border-radius: 50%;
background: $color;
}
.mask {
position: absolute;
bottom: 12px;
left: 4px;
width: 26px;
height: 26px;
border-radius: 50%;
background: $stage-ink;
transform: translateX(-60px);
animation: mask-slide $duration $easing infinite;
}
@keyframes mask-slide {
0%, 15% { transform: translateX(-60px); }
40%, 60% { transform: translateX(4px); }
85%, 100% { transform: translateX(-60px); }
}
06Flip-card transition
The motion
The whole toggle is a card with two faces glued back to back inside a perspective wrapper, and rotateY spins it from a sun face to a moon face and back again, the same motion as flipping a coin.

<main class="stage">
<div class="card">
<div class="card__inner">
<div class="card__face card__face--front">
<span class="card__icon card__icon--sun"></span>
<p class="card__label">Light</p>
</div>
<div class="card__face card__face--back">
<span class="card__icon card__icon--moon"></span>
<p class="card__label">Dark</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.card {
width: clamp(150px, 46vw, 180px); height: clamp(110px, 40vw, 140px);
perspective: 900px;
}
.card__inner {
position: relative; width: 100%; height: 100%; transform-style: preserve-3d;
animation: flip $dur-loop $easing infinite;
}
.card__face {
position: absolute; inset: 0; display: grid; place-items: center; gap: 8px;
border-radius: $r-card; box-shadow: $shadow-raised; backface-visibility: hidden;
}
.card__face--front { background: $subject-cream; color: $subject-ink; }
.card__face--back { background: $stage-ink; color: $subject-cream; transform: rotateY(180deg); }
.card__icon { width: 30px; height: 30px; border-radius: 50%; }
.card__icon--sun { background: $stage-yellow; }
.card__icon--moon { background: $subject-cream; }
.card__label { margin: 0; font-weight: 600; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit; }
// 앞면(라이트) → 뒷면(다크) → 다시 앞면. perspective + rotateY 만 쓴다
@keyframes flip {
0%, 15% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
85%, 100% { transform: rotateY(360deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 앞면(라이트)에 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.card__inner { animation: none !important; transform: rotateY(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.card {
width: clamp(150px, 46vw, 180px);
height: clamp(110px, 40vw, 140px);
perspective: 900px;
}
.card__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
animation: flip 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
gap: 8px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
backface-visibility: hidden;
}
.card__face--front {
background: #fff7e6;
color: #17141a;
}
.card__face--back {
background: #17141a;
color: #fff7e6;
transform: rotateY(180deg);
}
.card__icon {
width: 30px;
height: 30px;
border-radius: 50%;
}
.card__icon--sun {
background: #ffd23f;
}
.card__icon--moon {
background: #fff7e6;
}
.card__label {
margin: 0;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes flip {
0%, 15% {
transform: rotateY(0deg);
}
50% {
transform: rotateY(180deg);
}
85%, 100% {
transform: rotateY(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.card__inner {
animation: none !important;
transform: rotateY(0deg);
}
}import type { CSSProperties } from "react";
import styles from "./FlipCardToggle.module.scss";
export type FlipCardToggleProps = {
dark: boolean;
onToggle?: () => void;
/** 카드가 뒤집히는 시간 (기본 600ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function FlipCardToggle({
dark,
onToggle,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
className = "",
}: FlipCardToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<button
type="button"
className={`${styles.card} ${className}`}
style={vars}
data-dark={dark}
aria-label="다크모드 전환"
onClick={onToggle}
>
<span className={styles.inner}>
<span className={`${styles.face} ${styles.front}`}>라이트</span>
<span className={`${styles.face} ${styles.back}`}>다크</span>
</span>
</button>
);
}06 · Flip-card transition — Measured area 22.674% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It works as a card-style setting switch or inside a profile menu, wherever the control already looks like a small object rather than a flat rectangle.
.card {
perspective: 900px;
}
.card__inner {
position: relative;
transform-style: preserve-3d;
animation: flip $duration $easing infinite;
}
.card__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.card__face--back {
transform: rotateY(180deg);
}
@keyframes flip {
0%, 15% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
85%, 100% { transform: rotateY(360deg); }
}
07Icon morph transition
What it does
Six rays fold flat around a central circle while its color and opacity shift underneath them, so the sun's points retract into what reads as one of the moon's craters instead of two separate icons trading places.
<main class="stage">
<div class="panel">
<p class="panel__title">Toolbar</p>
<p class="panel__line"></p>
<span class="icon">
<span class="icon__core icon__core--sun"></span>
<span class="icon__core icon__core--moon"></span>
<span class="icon__ray" style="--i:0"></span>
<span class="icon__ray" style="--i:1"></span>
<span class="icon__ray" style="--i:2"></span>
<span class="icon__ray" style="--i:3"></span>
<span class="icon__ray" style="--i:4"></span>
<span class="icon__ray" style="--i:5"></span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-yellow;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.panel {
position: relative; overflow: hidden;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
background: $subject-cream; color: $subject-ink;
box-sizing: border-box; padding: clamp(12px, 5vw, 18px); text-align: left;
}
.panel__title { margin: 0 0 6px; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
.panel__line { margin: 0; width: 55%; height: 8px; border-radius: 4px; background: currentColor; opacity: .25; }
// 광선이 접혀 사라지고 원 안에 크레이터가 떠오른다. transform: scale·rotate 와 opacity 만 쓴다.
.icon {
position: absolute; top: clamp(10px, 4vw, 16px); right: clamp(10px, 4vw, 16px);
width: 34px; height: 34px;
}
.icon__core { position: absolute; inset: 6px; border-radius: 50%; }
.icon__core--sun { background: $color; opacity: 1; animation: core-light $dur-loop $easing infinite; }
.icon__core--moon { background: $stage-ink; opacity: 0; animation: core-dark $dur-loop $easing infinite; }
.icon__ray {
position: absolute; top: 50%; left: 50%; width: 3px; height: 8px; border-radius: 2px;
background: $color; transform-origin: 50% 17px; // 규칙8: 아이콘 중심 회전축
transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1);
animation: ray-fold $dur-loop $easing infinite;
}
@keyframes core-light { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes core-dark { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
@keyframes ray-fold {
0%, 15% { opacity: 1; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1); }
40%, 60% { opacity: 0; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(0); }
85%, 100% { opacity: 1; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 태양 상태로 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.icon__core--sun, .icon__core--moon, .icon__ray { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
position: relative;
overflow: hidden;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
text-align: left;
}
.panel__title {
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.panel__line {
margin: 0;
width: 55%;
height: 8px;
border-radius: 4px;
background: currentColor;
opacity: 0.25;
}
.icon {
position: absolute;
top: clamp(10px, 4vw, 16px);
right: clamp(10px, 4vw, 16px);
width: 34px;
height: 34px;
}
.icon__core {
position: absolute;
inset: 6px;
border-radius: 50%;
}
.icon__core--sun {
background: #ffd23f;
opacity: 1;
animation: core-light 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.icon__core--moon {
background: #17141a;
opacity: 0;
animation: core-dark 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.icon__ray {
position: absolute;
top: 50%;
left: 50%;
width: 3px;
height: 8px;
border-radius: 2px;
background: #ffd23f;
transform-origin: 50% 17px;
transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1);
animation: ray-fold 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes core-light {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes core-dark {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes ray-fold {
0%, 15% {
opacity: 1;
transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1);
}
40%, 60% {
opacity: 0;
transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(0);
}
85%, 100% {
opacity: 1;
transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.icon__core--sun, .icon__core--moon, .icon__ray {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./IconMorphToggle.module.scss";
export type IconMorphToggleProps = {
dark: boolean;
onToggle?: () => void;
/** 광선이 접히는 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RayVars = CSSProperties & { "--i"?: number };
const RAYS = [0, 1, 2, 3, 4, 5];
export default function IconMorphToggle({
dark,
onToggle,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
className = "",
}: IconMorphToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button
type="button"
className={`${styles.icon} ${className}`}
style={vars}
data-dark={dark}
aria-label="다크모드 전환"
onClick={onToggle}
>
<span className={`${styles.core} ${styles.coreLight}`} />
<span className={`${styles.core} ${styles.coreDark}`} />
{RAYS.map((i) => {
const rayVars: RayVars = { "--i": i };
return <span key={i} className={styles.ray} style={rayVars} />;
})}
</button>
);
}07 · Icon morph transition — Measured area 0.483% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It's built for an icon-only button — a toolbar toggle where a label doesn't fit and the icon has to do all the explaining on its own.
.icon__ray {
position: absolute;
top: 50%;
left: 50%;
width: 3px;
height: 8px;
border-radius: 2px;
background: $color;
transform-origin: 50% 17px;
animation: ray-fold $duration $easing infinite;
}
@keyframes ray-fold {
0%, 15% { opacity: 1; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1); }
40%, 60% { opacity: 0; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(0); }
85%, 100% { opacity: 1; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1); }
}
08Whole-page color fade
What you see
Background and text sit as two stacked layers that cross-fade on the same curve, so the page trades one full color scheme for the other instead of any single property being interpolated in place.

<main class="stage">
<div class="panel">
<div class="panel__face panel__face--light">
<p class="panel__title">Document</p>
<p class="panel__line"></p>
<p class="panel__line panel__line--short"></p>
</div>
<div class="panel__face panel__face--dark">
<p class="panel__title">Document</p>
<p class="panel__line"></p>
<p class="panel__line panel__line--short"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.panel {
position: relative; overflow: hidden;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
}
.panel__face {
position: absolute; inset: 0; box-sizing: border-box;
padding: clamp(12px, 5vw, 18px); text-align: left;
}
.panel__face--light {
background: $subject-cream; color: $subject-ink;
opacity: 1; animation: face-light $dur-loop $easing infinite;
}
.panel__face--dark {
background: $stage-ink; color: $subject-cream;
opacity: 0; animation: face-dark $dur-loop $easing infinite;
}
.panel__title { margin: 0 0 6px; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
.panel__line { margin: 0 0 6px; width: 70%; height: 8px; border-radius: 4px; background: currentColor; opacity: .25; }
.panel__line--short { width: 40%; }
// 배경과 글자색이 같은 곡선을 따라 함께 뒤바뀐다. 토글 없이 페이지 전체가 페이드한다
@keyframes face-light { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes face-dark { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
// ── 접근성: 움직임 줄이기 설정이면 라이트 상태로 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark { animation: 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);
}
.panel {
position: relative;
overflow: hidden;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.panel__face {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
text-align: left;
}
.panel__face--light {
background: #fff7e6;
color: #17141a;
opacity: 1;
animation: face-light 2s ease-out infinite;
}
.panel__face--dark {
background: #17141a;
color: #fff7e6;
opacity: 0;
animation: face-dark 2s ease-out infinite;
}
.panel__title {
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.panel__line {
margin: 0 0 6px;
width: 70%;
height: 8px;
border-radius: 4px;
background: currentColor;
opacity: 0.25;
}
.panel__line--short {
width: 40%;
}
@keyframes face-light {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes face-dark {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.panel__face--light, .panel__face--dark {
animation: none !important;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ColorFadeToggle.module.scss";
export type ColorFadeToggleProps = {
dark: boolean;
/** 전체 페이드가 끝나는 시간 (기본 800ms) */
duration?: number;
easing?: string;
lightContent: ReactNode;
darkContent: ReactNode;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ColorFadeToggle({
dark,
duration = 800,
easing = "ease-out",
lightContent,
darkContent,
className = "",
}: ColorFadeToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={`${styles.panel} ${className}`} style={vars} data-dark={dark}>
<div className={styles.light}>{lightContent}</div>
<div className={styles.dark}>{darkContent}</div>
</div>
);
}08 · Whole-page color fade — Measured area 26.961% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It fits a full document view — a reader, a viewer, or anywhere the whole canvas needs to change at once rather than one button doing it alone.
.panel__face--light {
background: $subject-cream;
color: $subject-ink;
opacity: 1;
animation: face-light $duration $easing infinite;
}
.panel__face--dark {
background: $stage-ink;
color: $subject-cream;
opacity: 0;
animation: face-dark $duration $easing infinite;
}
@keyframes face-light {
0%, 15% { opacity: 1; }
40%, 60% { opacity: 0; }
85%, 100% { opacity: 1; }
}
09Light/dark/auto 3-state
The motion
A pill-shaped track with three slots moves a single indicator thumb through light, dark, and auto in sequence, translateX doing all the work instead of three buttons standing in for three states.

<main class="stage">
<div class="panel">
<p class="panel__title">Preferences</p>
<div class="segmented">
<span class="segmented__slot segmented__slot--light"></span>
<span class="segmented__slot segmented__slot--dark"></span>
<span class="segmented__slot segmented__slot--auto"></span>
<span class="segmented__thumb"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.panel {
position: relative;
width: clamp(200px, 70vw, 260px); height: clamp(120px, 42vw, 150px);
border-radius: $r-card; box-shadow: $shadow-raised;
background: $subject-cream; color: $subject-ink;
box-sizing: border-box; padding: clamp(14px, 5vw, 20px);
display: flex; flex-direction: column; justify-content: center; gap: clamp(12px, 4vw, 18px);
}
.panel__title { margin: 0; font-weight: 700; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; }
// 3칸짜리 트랙 위 인디케이터가 라이트→다크→자동 순으로 이동한다. transform: translateX 만 쓴다.
.segmented {
position: relative; display: flex; width: 96px; height: 28px;
border-radius: $r-pill; background: rgba(23, 20, 26, .08); overflow: hidden;
}
.segmented__slot { flex: 1; }
.segmented__thumb {
position: absolute; top: 3px; left: 3px; width: 26px; height: 22px; border-radius: $r-pill;
background: $color; box-shadow: $shadow-press;
transform: translateX(0); animation: thumb-move $dur-loop $easing infinite;
}
@keyframes thumb-move {
0%, 22% { transform: translateX(0); }
33%, 55% { transform: translateX(32px); }
66%, 88% { transform: translateX(64px); }
100% { transform: translateX(0); }
}
// ── 접근성: 움직임 줄이기 설정이면 첫 칸(라이트)에 고정 ──────────
@media (prefers-reduced-motion: reduce) {
.segmented__thumb { animation: none !important; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
position: relative;
width: clamp(200px, 70vw, 260px);
height: clamp(120px, 42vw, 150px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
box-sizing: border-box;
padding: clamp(14px, 5vw, 20px);
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(12px, 4vw, 18px);
}
.panel__title {
margin: 0;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.segmented {
position: relative;
display: flex;
width: 96px;
height: 28px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.segmented__slot {
flex: 1;
}
.segmented__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 26px;
height: 22px;
border-radius: 999px;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
animation: thumb-move 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes thumb-move {
0%, 22% {
transform: translateX(0);
}
33%, 55% {
transform: translateX(32px);
}
66%, 88% {
transform: translateX(64px);
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.segmented__thumb {
animation: none !important;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./ThreeStateToggle.module.scss";
export type ThreeStateMode = "light" | "dark" | "auto";
export type ThreeStateToggleProps = {
mode: ThreeStateMode;
onChange?: (mode: ThreeStateMode) => void;
/** 인디케이터가 옮겨가는 시간 (기본 350ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const MODES: ThreeStateMode[] = ["light", "dark", "auto"];
export default function ThreeStateToggle({
mode,
onChange,
duration = 350,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
className = "",
}: ThreeStateToggleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={`${styles.track} ${className}`} style={vars} data-mode={mode} role="radiogroup" aria-label="환경설정">
{MODES.map((m) => (
<button
key={m}
type="button"
className={styles.slot}
role="radio"
aria-checked={m === mode}
onClick={() => onChange?.(m)}
/>
))}
<span className={styles.thumb} />
</span>
);
}09 · Light/dark/auto 3-state — Measured area 1.517% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It belongs on a detailed settings page or a desktop app's preferences, anywhere a reader expects a third option beyond a plain on/off.
.segmented__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 26px;
height: 22px;
border-radius: $r-pill;
background: $color;
transform: translateX(0);
animation: thumb-move $duration $easing infinite;
}
@keyframes thumb-move {
0%, 22% { transform: translateX(0); }
33%, 55% { transform: translateX(32px); }
66%, 88% { transform: translateX(64px); }
100% { transform: translateX(0); }
}
Where it breaks — the traps
Why the preview loops
All nine flip from light to dark and back to light on their own inside the grid above, on a fixed two-second loop — there's nothing to click up there, only something to watch, because a fixed-size iframe with no real page behind it has no scroll position or click target to react to. The loop stands in for the interaction; the vanilla files in the zip are the ones that actually respond to a press.
Trap two — color-scheme on the root
A second trap catches anyone who only recolors the background and the text and stops there: without setting color-scheme on the root element, native form controls, scrollbars, and even the browser's own UI keep rendering light, so the page looks only half switched no matter how dark the body itself gets. 04's light-dark() approach depends on that same property being declared first — the function reads color-scheme to decide which of its two values to hand back — and its own support trails behind it: Chrome ships light-dark() from version 123, Safari from 17.5, and Firefox from 120, well after color-scheme itself, which every modern browser already understands. Anyone shipping 04 to production should open the zip and read style.css directly, and it unlocks with kjdwbr3q typed in by hand rather than guessed.
Trap three — hard-coded offsets
The third trap is easy to miss on 03 and 05 specifically: both position their moving shape with an absolute top/left/right pulled straight from the demo's fixed panel size, so dropping either one into a wider real card without adjusting those offsets leaves the circle or the mask centered on the wrong spot entirely.
Accessibility
Reduced motion
Every one of the nine respects prefers-reduced-motion, but the fallback isn't the same shape twice. 01, 02, 05, 06, and 09 just stop moving and settle on whichever state they were already in — the sun stays a sun, the knob stays put — since nothing about those five is decorative enough to keep once motion is off. 03's circle never grows and 07's rays never fold; both jump straight to the finished shape instead of playing the reveal. MDN's guide to prefers-reduced-motion covers how widely browsers support the query itself.
@media (prefers-reduced-motion: reduce) {
.toggle__sun, .toggle__moon, .knob, .wipe, .mask,
.card__inner, .icon__ray, .segmented__thumb { animation: none !important; }
}
Nine more patterns like these live in the CSS category, and what this site actually ships gets explained on the about page.
FAQ
Do I need JavaScript for a css dark mode toggle?
Not for the motion itself. Every animation in this set runs on CSS transforms and opacity, so a css dark mode switch without javascript is entirely possible for the transition itself — you still need one line of JS (or a form control) to actually flip a class or attribute, since CSS alone can't remember a user's choice between visits.
What's the difference between a dark mode toggle and a dark mode switch?
Nothing structural — "toggle" and "switch" describe the same two-state control, and search engines treat dark theme toggle css and css dark mode switch as the same query. The naming difference usually just tracks which shape the design uses: a switch tends to look like 02's sliding knob, while a toggle covers icon-based versions like 01 and 07 too.
Can I add a css dark mode toggle button to an existing site without a redesign?
Yes, as long as the site's colors already route through custom properties. Any of these nine can sit inside a header as an html css dark mode toggle button on its own, and swapping the two color values it touches is a smaller change than restyling every component by hand.









