05Theme button where the sun and moon trade places
Two icons sit stacked and trade places in a steps(1, end) cut instead of a half blend, and the preview card inside flips between light and dark at the same instant.
Best forDay and night theme switching in document and note apps
steps(1, end)aria-pressedrotate
<main class="stage">
<section class="tm is-demo" id="tm" aria-label="A theme button where sun and moon trade places">
<article class="tm__card">
<header class="tm__head">
<span class="tm__avatar" aria-hidden="true"></span>
<span class="tm__who">My notes</span>
</header>
<p class="tm__line tm__line--a">Bright paper by day</p>
<p class="tm__line tm__line--b">Soft ink by night</p>
<p class="tm__line tm__line--c">Whichever rests the eyes</p>
</article>
<button class="tm__btn" type="button" id="tm-btn" aria-pressed="false" aria-label="Switch to the night theme">
<span class="tm__disc" aria-hidden="true">
<svg class="tm__sun" viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M184,128a56,56,0,1,1-56-56A56,56,0,0,1,184,128Z" opacity=".2"/><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg>
<svg class="tm__moon" viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M227.89,147.89A96,96,0,1,1,108.11,28.11,96.09,96.09,0,0,0,227.89,147.89Z" opacity=".2"/><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></svg>
</span>
<span class="tm__t"><span class="tm__t-day">Day</span><span class="tm__t-night">Night</span></span>
</button>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
// 해와 달이 맞바뀌는 테마 버튼 — 아이콘·카드 전환은 전부 steps(1, end) 컷
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
$card-day: $subject-cream;
$card-night: color.mix($subject-lilac, $stage-ink, 18%);
.tm {
display: grid;
justify-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tm__card {
box-sizing: border-box;
width: min(236px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $card-day;
color: $subject-ink;
}
.tm__head {
display: flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-2;
}
.tm__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: $subject-blue;
}
.tm__who { font-size: 11px; font-weight: 700; line-height: 1.3; }
.tm__line { margin: 0; font-size: 12px; line-height: 1.5; }
.tm__line--a { opacity: .95; }
.tm__line--b { opacity: .7; }
.tm__line--c { opacity: .45; }
// ── 토글 버튼: 해와 달 두 아이콘을 겹쳐 두고 컷으로 맞바꾼다 ──
.tm__btn {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3 $sp-1 $sp-1;
border: 1px solid rgba(255, 247, 230, .24);
border-radius: $r-pill;
background: color.mix($subject-cream, $stage-ink, 12%);
color: $subject-cream;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.tm__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tm__disc {
position: relative;
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: $stage-yellow;
color: $subject-ink;
font-size: 20px;
transition: transform $dur-base $easing;
}
.tm__sun,
.tm__moon {
position: absolute;
grid-area: 1 / 1;
transition: opacity 0ms, transform $dur-base $easing;
}
.tm__moon { opacity: 0; transform: rotate(-90deg) scale(.4); }
// 낮·밤 글자도 컷으로 맞바뀐다
.tm__t { position: relative; display: inline-grid; }
.tm__t-day,
.tm__t-night { grid-area: 1 / 1; }
.tm__t-night { opacity: 0; }
.tm.is-dark .tm__t-day { opacity: 0; }
.tm.is-dark .tm__t-night { opacity: 1; }
// 정적 경로 — 밤이면 달이 살고 해가 비켜난다 (컷)
.tm.is-dark .tm__card { background: $card-night; color: $subject-cream; }
.tm.is-dark .tm__sun { opacity: 0; transform: rotate(90deg) scale(.4); }
.tm.is-dark .tm__moon { opacity: 1; transform: rotate(0deg) scale(1); }
.tm.is-dark .tm__disc { background: $subject-lilac; color: $stage-ink; transform: rotate(180deg); }
// ── 격자 미리보기 전용 2초 루프: 낮 → 컷 → 밤 → 컷 → 낮 ──
// 컷(41%/82%) 앞뒤로 회전 구간을 넓게 둬 프레임 수를 채운다
@keyframes tm-loop-sun {
0%, 36% { opacity: 1; transform: rotate(0deg) scale(1); }
52%, 78% { opacity: 0; transform: rotate(90deg) scale(.4); }
94%, 100% { opacity: 1; transform: rotate(0deg) scale(1); }
}
@keyframes tm-loop-moon {
0%, 36% { opacity: 0; transform: rotate(-90deg) scale(.4); }
52%, 78% { opacity: 1; transform: rotate(0deg) scale(1); }
94%, 100% { opacity: 0; transform: rotate(-90deg) scale(.4); }
}
@keyframes tm-loop-card {
0%, 44% { background: $card-day; color: $subject-ink; }
45%, 79% { background: $card-night; color: $subject-cream; }
80%, 100% { background: $card-day; color: $subject-ink; }
}
@keyframes tm-loop-disc {
0%, 36% { background: $stage-yellow; color: $subject-ink; transform: rotate(0deg); }
52%, 78% { background: $subject-lilac; color: $stage-ink; transform: rotate(180deg); }
94%, 100% { background: $stage-yellow; color: $subject-ink; transform: rotate(360deg); }
}
@keyframes tm-loop-day {
0%, 44% { opacity: 1; }
45%, 79% { opacity: 0; }
80%, 100% { opacity: 1; }
}
@keyframes tm-loop-night {
0%, 44% { opacity: 0; }
45%, 79% { opacity: 1; }
80%, 100% { opacity: 0; }
}
.tm.is-demo {
.tm__t-day {
animation-name: tm-loop-day;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm__t-night {
animation-name: tm-loop-night;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm__sun {
animation-name: tm-loop-sun;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tm__moon {
animation-name: tm-loop-moon;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tm__card {
animation-name: tm-loop-card;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm__disc {
animation-name: tm-loop-disc;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.tm__card { padding: $sp-2; width: min(224px, 92vw); }
.tm__line { font-size: 11px; }
.tm__btn { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__sun,
.tm.is-demo .tm__moon,
.tm.is-demo .tm__card,
.tm.is-demo .tm__disc,
.tm.is-demo .tm__t-day,
.tm.is-demo .tm__t-night { animation: none !important; transform: none !important; }
// 밤 상태의 결과를 남긴다 — 달이 보이고 카드는 어둡다
.tm.is-demo .tm__sun { opacity: 0 !important; }
.tm.is-demo .tm__moon { opacity: 1 !important; }
.tm.is-demo .tm__card { background: $card-night !important; color: $subject-cream !important; }
.tm.is-demo .tm__disc { background: $subject-lilac !important; }
.tm.is-demo .tm__t-day { opacity: 0 !important; }
.tm.is-demo .tm__t-night { opacity: 1 !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);
}
.tm {
display: grid;
justify-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tm__card {
box-sizing: border-box;
width: min(236px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
}
.tm__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.tm__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: #2f6df6;
}
.tm__who {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.tm__line {
margin: 0;
font-size: 12px;
line-height: 1.5;
}
.tm__line--a {
opacity: 0.95;
}
.tm__line--b {
opacity: 0.7;
}
.tm__line--c {
opacity: 0.45;
}
.tm__btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 4px 12px 4px 4px;
border: 1px solid rgba(255, 247, 230, 0.24);
border-radius: 999px;
background: rgb(19.937254902%, 18.5254901961%, 19.7960784314%);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.tm__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.tm__disc {
position: relative;
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #ffd23f;
color: #17141a;
font-size: 20px;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__sun,
.tm__moon {
position: absolute;
grid-area: 1/1;
transition: opacity 0ms, transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__moon {
opacity: 0;
transform: rotate(-90deg) scale(0.4);
}
.tm__t {
position: relative;
display: inline-grid;
}
.tm__t-day,
.tm__t-night {
grid-area: 1/1;
}
.tm__t-night {
opacity: 0;
}
.tm.is-dark .tm__t-day {
opacity: 0;
}
.tm.is-dark .tm__t-night {
opacity: 1;
}
.tm.is-dark .tm__card {
background: rgb(20.4549019608%, 17.7254901961%, 26.3607843137%);
color: #fff7e6;
}
.tm.is-dark .tm__sun {
opacity: 0;
transform: rotate(90deg) scale(0.4);
}
.tm.is-dark .tm__moon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.tm.is-dark .tm__disc {
background: #b9a0ff;
color: #17141a;
transform: rotate(180deg);
}
@keyframes tm-loop-sun {
0%, 36% {
opacity: 1;
transform: rotate(0deg) scale(1);
}
52%, 78% {
opacity: 0;
transform: rotate(90deg) scale(0.4);
}
94%, 100% {
opacity: 1;
transform: rotate(0deg) scale(1);
}
}
@keyframes tm-loop-moon {
0%, 36% {
opacity: 0;
transform: rotate(-90deg) scale(0.4);
}
52%, 78% {
opacity: 1;
transform: rotate(0deg) scale(1);
}
94%, 100% {
opacity: 0;
transform: rotate(-90deg) scale(0.4);
}
}
@keyframes tm-loop-card {
0%, 44% {
background: #fff7e6;
color: #17141a;
}
45%, 79% {
background: rgb(20.4549019608%, 17.7254901961%, 26.3607843137%);
color: #fff7e6;
}
80%, 100% {
background: #fff7e6;
color: #17141a;
}
}
@keyframes tm-loop-disc {
0%, 36% {
background: #ffd23f;
color: #17141a;
transform: rotate(0deg);
}
52%, 78% {
background: #b9a0ff;
color: #17141a;
transform: rotate(180deg);
}
94%, 100% {
background: #ffd23f;
color: #17141a;
transform: rotate(360deg);
}
}
@keyframes tm-loop-day {
0%, 44% {
opacity: 1;
}
45%, 79% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes tm-loop-night {
0%, 44% {
opacity: 0;
}
45%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
.tm.is-demo .tm__t-day {
animation-name: tm-loop-day;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__t-night {
animation-name: tm-loop-night;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__sun {
animation-name: tm-loop-sun;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__moon {
animation-name: tm-loop-moon;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__card {
animation-name: tm-loop-card;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__disc {
animation-name: tm-loop-disc;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.tm__card {
padding: 8px;
width: min(224px, 92vw);
}
.tm__line {
font-size: 11px;
}
.tm__btn {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__sun,
.tm.is-demo .tm__moon,
.tm.is-demo .tm__card,
.tm.is-demo .tm__disc,
.tm.is-demo .tm__t-day,
.tm.is-demo .tm__t-night {
animation: none !important;
transform: none !important;
}
.tm.is-demo .tm__sun {
opacity: 0 !important;
}
.tm.is-demo .tm__moon {
opacity: 1 !important;
}
.tm.is-demo .tm__card {
background: rgb(20.4549019608%, 17.7254901961%, 26.3607843137%) !important;
color: #fff7e6 !important;
}
.tm.is-demo .tm__disc {
background: #b9a0ff !important;
}
.tm.is-demo .tm__t-day {
opacity: 0 !important;
}
.tm.is-demo .tm__t-night {
opacity: 1 !important;
}
}// 테마 토글: 해와 달은 반투명으로 섞지 않고 한 프레임 컷으로 맞바뀐다
var tm = document.getElementById('tm');
var tmBtn = document.getElementById('tm-btn');
var tmDark = false;
tmBtn.addEventListener('click', function () {
tm.classList.remove('is-demo');
tmDark = !tmDark;
tm.classList.toggle('is-dark', tmDark);
tmBtn.setAttribute('aria-pressed', tmDark ? 'true' : 'false');
tmBtn.setAttribute('aria-label', tmDark ? 'Switch to the day theme' : 'Switch to the night theme');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThemeMorphButton.module.scss";
export type ThemeMorphButtonProps = {
who?: string;
/** 미리보기 판의 세 줄. 위에서 아래로 옅어진다 */
lines?: string[];
dayLabel?: string;
nightLabel?: string;
/** 낮일 때의 버튼 이름 (누르면 밤이 된다) */
toNightLabel?: string;
/** 밤일 때의 버튼 이름 (누르면 낮이 된다) */
toDayLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
/** 해·달·원반이 돌아 자리를 바꾸는 시간(ms) */
duration?: number;
easing?: string;
/** 밤 원반의 색 — 초점 테두리도 같은 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_LINES = ["Bright paper by day", "Soft ink by night", "Whichever is easier on the eyes"];
function Sun({ cls }: { cls: string }) {
return (
<svg className={cls} viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M184,128a56,56,0,1,1-56-56A56,56,0,0,1,184,128Z" opacity=".2" />
<path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z" />
</svg>
);
}
function Moon({ cls }: { cls: string }) {
return (
<svg className={cls} viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M227.89,147.89A96,96,0,1,1,108.11,28.11,96.09,96.09,0,0,0,227.89,147.89Z" opacity=".2" />
<path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z" />
</svg>
);
}
export default function ThemeMorphButton({
who = "My notes",
lines = DEFAULT_LINES,
dayLabel = "Day",
nightLabel = "Night",
toNightLabel = "Switch to the night theme",
toDayLabel = "Switch to the day theme",
groupLabel = "Theme button where the sun and moon trade places",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b9a0ff",
}: ThemeMorphButtonProps) {
const [dark, setDark] = useState(false);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 누르면 미리보기 루프를 끄고 사람이 고른 테마만 남긴다
function toggle() {
setLive(false);
setDark((v) => !v);
}
const cls = [styles.tm, live ? styles.isDemo : "", dark ? styles.isDark : ""].filter(Boolean).join(" ");
const fade = [styles.lineA, styles.lineB, styles.lineC];
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<article className={styles.card}>
<header className={styles.head}>
<span className={styles.avatar} aria-hidden="true" />
<span className={styles.who}>{who}</span>
</header>
{lines.map((t, i) => (
<p key={t} className={[styles.line, fade[i] || ""].filter(Boolean).join(" ")}>
{t}
</p>
))}
</article>
<button
className={styles.btn}
type="button"
aria-pressed={dark}
aria-label={dark ? toDayLabel : toNightLabel}
onClick={toggle}
>
{/* 해와 달은 반투명으로 섞지 않고 한 프레임 컷으로 맞바뀐다 */}
<span className={styles.disc} aria-hidden="true">
<Sun cls={styles.sun} />
<Moon cls={styles.moon} />
</span>
<span className={styles.t}>
<span className={styles.tDay}>{dayLabel}</span>
<span className={styles.tNight}>{nightLabel}</span>
</span>
</button>
</section>
);
}05 Theme button where the sun and moon trade places — Measured area 17.817% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · 9 Icon Button CSS Patterns — Press, Drag, Expand
The post this demo lives in
If you like this effect - similar demos
Buttons03 One button holding on and off9 button state css patterns, loading to done
Buttons07 Seesaw tilt switchCSS Switch Button 9 Ways — Rocker to Tape Eject
Buttons01 Accordion-reveal value toggle — when the button's open value changes, its rounding and shadow values change along, presenting the content belowExpanding Button UI: 9 Shape-Morph Patterns