css countdown timer 9가지 — 링·모래·도화선으로 줄어드는 남은 시간
css countdown timer 의 남은 시간은 숫자로 읽기 전에 모양이 줄어드는 속도로 먼저 느껴진다 — 01번 링과 03번 모래가 그 속도를 계산으로 정한다.
아홉 가지 카운트다운 타이머를 화면에서 바로 돌려 본다
01 cd-ring-drain-hue-shift
02 cd-flip-digit-hinge-shadow
03 cd-hourglass-sand-grains
04 cd-fuse-burn-spark-trail
05 cd-dial-sweep-tick-overshoot
06 cd-odometer-strip-motion-blur
07 cd-liquid-tank-slosh-level
08 cd-seven-segment-phosphor-glow
09 cd-launch-sequence-shockwave-hero
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 남은 비율로 색이 번지고 끝 구간에서 심박이 뛰는 줄어드는 링
- 02 접히는 각도에 맞춰 그림자가 따라 도는 플립 숫자 카드
- 03 모래알이 좁은 목을 지나 쌓이며 시간을 재는 모래시계
- 04 불붙은 점이 도화선 길이를 따라 걷고 불똥이 튀는 카운트다운
- 05 바늘이 눈금마다 한 박자 튕기며 지나온 부채꼴이 채워지는 다이얼
- 06 자릿수 띠가 빨리 돌수록 번지고 올림이 줄줄이 이어지는 주행계 숫자
- 07 수위가 내려갈수록 출렁이고 멈추면 가라앉는 액체 탱크 타이머
- 08 꺼진 세그먼트에 잔광이 남는 칠세그먼트 LED 카운트다운
- 09 3 2 1 마다 충격파가 퍼지고 0 에서 점화되는 발사 카운트다운 장면
css countdown timer 9가지 — 감쇠·스프링·경로 길이로 시간이 눈에 보인다
장면 하나는 480×300 캡처 기준 무대 안쪽에 시계 보기 칸과 버튼 줄을 세로로 8px 간격으로 쌓은 모양이다. 무대 안쪽 여백은 clamp(8px, 4vw, 24px) 라서 480 폭에서 19.2px 이고, 보기 칸 높이는 최대 196px 이며 320×200 화면에서는 126px 로 줄어든다. 버튼은 시작·멈춤과 다시 둘이고 높이 36px, 프로브로 잰 폭은 각각 60px 이다.
아홉 장면 모두 시간을 날짜 객체에서 읽지 않고, requestAnimationFrame 이 건네는 타임스탬프 차이를 elapsed 에 쌓아 남은 시간을 만든다. 자동 재생은 전체 시간에 보류 구간을 더한 한 바퀴를 돌고 끝에서 0 을 잠깐 보여 준 뒤 처음으로 돌아가며, 사용자가 버튼을 하나라도 누르면 그 루프는 끝나고 이후에는 한 번만 돈다. 시연 한 바퀴는 01·03·04·06·07 이 1500ms 에 보류 300ms, 02·08 이 150ms 칸 12개, 05 가 150ms 칸 10개, 09 가 450ms 칸 3개다.
시간 표시에는 밝힐 것이 하나 있다. 02·05·08·09 는 시연 중에 「1초」 한 칸을 배속으로 줄여 돌리고 사용자가 버튼을 누르는 순간 1000ms 단위로 바뀌며, 01·03·04·06·07 은 배속 없이 소수 첫째 자리 초를 실제 길이 그대로 보인다. 그래서 시연 화면에서 본 박자와 직접 눌러 돌릴 때의 박자가 네 장면에서는 다르다.
순서는 줄어드는 면이 어디서 나오느냐로 잡았다. 01 은 호 길이가 남은 비율 그 자체이고, 02·05 는 스프링 방정식을 직접 적분하며, 03·04·09 는 시드가 고정된 난수로 입자와 별을 굴린다. 06 은 자릿수 띠, 08 은 세그먼트 밝기, 07 은 수위에서 파도 진폭을 끌어낸다. 요리나 시험 남은 시간이면 01 부터, 행사 오픈이면 02·09 부터, 집중 타이머면 03 부터 보면 된다. 숫자가 올라가는 쪽이 궁금하면 CSS 숫자 증가 애니메이션 9종 — 한 줄 복붙으로를, 값을 게이지로 보이는 쪽은 원형 프로그레스바 CSS 9종 — 값만 바꾸면 되는를, 만료 시각이 있는 로더는 qr 코드 만료 카운트다운 css 애니메이션 9가지를 같이 열어 두면 좋다.
표 값은 측정.json 에서 그대로 옮겼고, 한 장면당 24장을 찍어 앞뒤 프레임 차이로 움직임을 센 결과다. 면적이 10% 아래인 것은 02 와 03 이고, 움직인 프레임은 01 이 20/23, 02 가 22/23 이며 나머지 일곱은 23/23 이다.
| 항목 | 누적면적 | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 링 | 32.4% | 59.4 | 20/23 |
| 02 플립 카드 | 5.2% | 110.0 | 22/23 |
| 03 모래시계 | 3.7% | 177.8 | 23/23 |
| 04 도화선 | 57.0% | 32.9 | 23/23 |
| 05 다이얼 | 15.0% | 132.7 | 23/23 |
| 06 주행계 | 33.6% | 101.1 | 23/23 |
| 07 액체 탱크 | 26.9% | 137.4 | 23/23 |
| 08 칠세그먼트 | 46.8% | 43.9 | 23/23 |
| 09 발사 장면 | 47.1% | 82.1 | 23/23 |
countdown timer html css javascript 를 기법별 코드로 본다
조각은 장면별 index.html 의 <script> 안에서 이어진 줄만 그대로 가져왔고 하나가 25줄 이하다. countdown timer css codepen 에서 고른 조각과 견줄 때는 남은 시간이 어느 변수에서 나오는지부터 보면 되는데, 여기서는 remainMs() 가 elapsed 에서 계산된다. 상수 이름은 각 절의 문장에 적어 두었으니 값을 바꿀 자리는 const 줄에서 찾으면 된다.
01남은 비율로 색이 번지고 끝 구간에서 심박이 뛰는 줄어드는 링
호 길이는 strokeDashoffset 에 둘레 301.6 과 남은 비율을 곱해 넣어 줄이고, 같은 비율을 세 색(민트·연노랑·분홍) 사이 선형 보간 blend 에 넣어 호 색을 옮긴다. 남은 비율이 PULSE_AT 0.4 아래로 내려가면 heartbeat 곡선이 링 굵기를 9 에서 최대 15 까지 흔든다. 요리 타이머나 시험 남은 시간 표시에 어울리고, 면적 32.4%·강도 59.4 에 20/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--ring">
<div class="cd__halo" data-r="halo"></div>
<svg class="cd__svg" viewBox="0 0 120 120" role="img" aria-label="줄어드는 링 타이머">
<circle class="cd__track" cx="60" cy="60" r="48"/>
<circle class="cd__arc" data-r="arc" cx="60" cy="60" r="48" transform="rotate(-90 60 60)"/>
</svg>
<p class="cd__read cd__read--center" role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd {
--c0: #{$subject-mint};
--c1: #{mix($stage-yellow, $subject-cream, 60%)};
--c2: #{$subject-pink};
}
.cd__view--ring { width: 100%; height: var(--view-h); display: grid; place-items: center; }
.cd__halo { position: absolute; inset: 0; }
.cd__svg { position: relative; display: block; width: var(--view-h); height: var(--view-h); }
.cd__track { fill: none; stroke: rgba($subject-cream, .14); stroke-width: 9; }
.cd__arc { fill: none; stroke-linecap: round; stroke-width: 9; }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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd {
--c0: #4cd4a6;
--c1: rgb(100%, 88.1568627451%, 50.9019607843%);
--c2: #ff9ebb;
}
.cd__view--ring {
width: 100%;
height: var(--view-h);
display: grid;
place-items: center;
}
.cd__halo {
position: absolute;
inset: 0;
}
.cd__svg {
position: relative;
display: block;
width: var(--view-h);
height: var(--view-h);
}
.cd__track {
fill: none;
stroke: rgba(255, 247, 230, 0.14);
stroke-width: 9;
}
.cd__arc {
fill: none;
stroke-linecap: round;
stroke-width: 9;
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const TOTAL_MS = 1500;
const HOLD_MS = 300;
const RING_R = 48;
const PULSE_AT = 0.4;
const PULSE_MS = 300;
const HUE_STOPS = ["--c0", "--c1", "--c2"];
let totalMs = TOTAL_MS;
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const arc = q('[data-r="arc"]');
const num = q('[data-r="num"]');
const halo = q('[data-r="halo"]');
const CIRC = 2 * Math.PI * RING_R;
const BASE_W = 9;
const PULSE_W = 6;
let stops = [[127, 220, 180], [255, 233, 159], [255, 158, 187]];
function parseColor(h) {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m)
return [255, 255, 255];
const ch = (v, pct) => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
// 색 토큰은 CSS 변수가 갖고, 여기서는 변수 이름만 들고 있다가 읽는다
function readStops() {
const cs = getComputedStyle(root);
stops = HUE_STOPS.map((n) => parseColor(cs.getPropertyValue(n).trim()));
}
// 남은 비율 f 를 세 색 사이 보간으로 바꾼다. u = 1 - f 가 0 이면 첫 색, 1 이면 마지막 색
function blend(u) {
const s = clamp(u, 0, 1) * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(s));
const f = s - i;
const a = stops[i];
const b = stops[i + 1];
return a.map((v, k) => Math.round(v + (b[k] - v) * f));
}
// 심박 곡선: 한 주기에 두 번 솟는다(쿵 쿵). 값은 0 에서 1 사이
function heartbeat(ph) {
const p = ph - Math.floor(ph);
const lub = Math.exp(-Math.pow((p - 0.1) / 0.06, 2));
const dub = 0.6 * Math.exp(-Math.pow((p - 0.3) / 0.07, 2));
return Math.min(1, lub + dub);
}
function resize() {
readStops();
arc.style.strokeDasharray = CIRC.toFixed(2);
}
function resetState() { }
function tick(dt) { }
function draw() {
const rem = remainMs();
const f = rem / totalMs;
arc.style.strokeDashoffset = (CIRC * (1 - f)).toFixed(2);
const col = blend(1 - f);
arc.style.stroke = "rgb(" + col.join(",") + ")";
arc.style.opacity = f > 0 ? "1" : "0";
let w = BASE_W;
if (!reduce && f > 0 && f < PULSE_AT) {
const ramp = 1 - f / PULSE_AT;
w += PULSE_W * ramp * heartbeat(elapsed / PULSE_MS);
}
arc.style.strokeWidth = w.toFixed(2);
// 같은 색과 같은 박동이 배경 후광에도 번진다: 링 바깥까지 남은 시간이 읽힌다
const beat = (w - BASE_W) / PULSE_W;
halo.style.background = "radial-gradient(ellipse at 50% 50%, rgba(" + col.join(",") + "," + (0.34 + 0.2 * beat).toFixed(3) + ") 0%, rgba(" + col.join(",") + ",0) 72%)";
num.textContent = (rem / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// RingDrainHueShift - vanilla 판(01-cd-ring-drain-hue-shift) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RingDrainHueShift.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RingDrainHueShift() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const TOTAL_MS = 1500;
const HOLD_MS = 300;
const RING_R = 48;
const PULSE_AT = 0.4;
const PULSE_MS = 300;
const HUE_STOPS = ["--c0", "--c1", "--c2"];
let totalMs = TOTAL_MS;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const arc = q<SVGCircleElement>('[data-r="arc"]');
const num = q<HTMLElement>('[data-r="num"]');
const halo = q<HTMLElement>('[data-r="halo"]');
const CIRC = 2 * Math.PI * RING_R;
const BASE_W = 9;
const PULSE_W = 6;
let stops: number[][] = [[127, 220, 180], [255, 233, 159], [255, 158, 187]];
function parseColor(h: string): number[] {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m) return [255, 255, 255];
const ch = (v: string, pct: string): number => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
// 색 토큰은 CSS 변수가 갖고, 여기서는 변수 이름만 들고 있다가 읽는다
function readStops(): void {
const cs = getComputedStyle(root);
stops = HUE_STOPS.map((n) => parseColor(cs.getPropertyValue(n).trim()));
}
// 남은 비율 f 를 세 색 사이 보간으로 바꾼다. u = 1 - f 가 0 이면 첫 색, 1 이면 마지막 색
function blend(u: number): number[] {
const s = clamp(u, 0, 1) * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(s));
const f = s - i;
const a = stops[i];
const b = stops[i + 1];
return a.map((v, k) => Math.round(v + (b[k] - v) * f));
}
// 심박 곡선: 한 주기에 두 번 솟는다(쿵 쿵). 값은 0 에서 1 사이
function heartbeat(ph: number): number {
const p = ph - Math.floor(ph);
const lub = Math.exp(-Math.pow((p - 0.1) / 0.06, 2));
const dub = 0.6 * Math.exp(-Math.pow((p - 0.3) / 0.07, 2));
return Math.min(1, lub + dub);
}
function resize(): void {
readStops();
arc.style.strokeDasharray = CIRC.toFixed(2);
}
function resetState(): void {}
function tick(dt: number): void {}
function draw(): void {
const rem = remainMs();
const f = rem / totalMs;
arc.style.strokeDashoffset = (CIRC * (1 - f)).toFixed(2);
const col = blend(1 - f);
arc.style.stroke = "rgb(" + col.join(",") + ")";
arc.style.opacity = f > 0 ? "1" : "0";
let w = BASE_W;
if (!reduce && f > 0 && f < PULSE_AT) {
const ramp = 1 - f / PULSE_AT;
w += PULSE_W * ramp * heartbeat(elapsed / PULSE_MS);
}
arc.style.strokeWidth = w.toFixed(2);
// 같은 색과 같은 박동이 배경 후광에도 번진다: 링 바깥까지 남은 시간이 읽힌다
const beat = (w - BASE_W) / PULSE_W;
halo.style.background = "radial-gradient(ellipse at 50% 50%, rgba(" + col.join(",") + "," + (0.34 + 0.2 * beat).toFixed(3) + ") 0%, rgba(" + col.join(",") + ",0) 72%)";
num.textContent = (rem / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--ring")}>
<div className={cx("cd__halo")} data-r="halo"></div>
<svg className={cx("cd__svg")} viewBox="0 0 120 120" role="img" aria-label="줄어드는 링 타이머">
<circle className={cx("cd__track")} cx="60" cy="60" r="48"/>
<circle className={cx("cd__arc")} data-r="arc" cx="60" cy="60" r="48" transform="rotate(-90 60 60)"/>
</svg>
<p className={cx("cd__read cd__read--center")} role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}01 · 남은 비율로 색이 번지고 끝 구간에서 심박이 뛰는 줄어드는 링 — 측정 누적면적 32.376% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function blend(u) {
const s = clamp(u, 0, 1) * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(s));
const f = s - i;
const a = stops[i];
const b = stops[i + 1];
return a.map((v, k) => Math.round(v + (b[k] - v) * f));
}
// 심박 곡선: 한 주기에 두 번 솟는다(쿵 쿵). 값은 0 에서 1 사이
function heartbeat(ph) {
const p = ph - Math.floor(ph);
const lub = Math.exp(-Math.pow((p - 0.1) / 0.06, 2));
const dub = 0.6 * Math.exp(-Math.pow((p - 0.3) / 0.07, 2));
return Math.min(1, lub + dub);
}
02접히는 각도에 맞춰 그림자가 따라 도는 플립 숫자 카드
윗 잎을 STIFFNESS 2200 의 스프링으로 접고 접힌 각도의 사인값에 SHADE_MAX 0.6 을 곱해 그림자 농도로 쓰며, 소스의 감쇠 식(2 * 0.55 * sqrt(STIFFNESS))으로 계산한 넘침은 12.6% 이고, 각도는 clamp(c.x, 0, 1.12) 로 잘려 최대 201.6°(180°×1.12)다. 아래 setTarget 은 접히는 중에 새 숫자가 오면 앞 접힘을 c.cur = c.nxt 로 굳히고 x 를 0 으로 되돌려 새로 접고, 움직임 줄이기에서는 접지 않고 바로 바꾼다. 이벤트 오픈 카운트다운에 어울리고, 면적 5.2%·강도 110.0 에 22/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view">
<div class="cd__cards" data-r="cards" role="timer" aria-label="남은 시간 카드"></div>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-paper;
$fg: $subject-ink;
$btn: rgba($subject-ink, .1);
$card-bg: $subject-ink;
$card-fg: $subject-cream;
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd__view { display: grid; place-items: center; }
.cd__cards { display: flex; gap: $sp-3; --cw: clamp(72px, 20vw, 108px); }
.cd__card {
position: relative;
width: var(--cw);
height: calc(var(--cw) * 1.4);
transform-style: preserve-3d;
}
.cd__half {
position: absolute;
left: 0;
width: 100%;
height: 50%;
overflow: hidden;
background: $card-bg;
color: $card-fg;
backface-visibility: hidden;
}
.cd__half--top { top: 0; border-radius: $r-sm $r-sm 0 0; transform-origin: 50% 100%; border-bottom: 1px solid rgba($subject-cream, .12); }
.cd__half--bot { bottom: 0; border-radius: 0 0 $r-sm $r-sm; transform-origin: 50% 0; }
.cd__num {
position: absolute;
left: 0;
width: 100%;
height: 200%;
display: grid;
place-items: center;
font-size: calc(var(--cw) * .98);
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.cd__half--top .cd__num { top: 0; }
.cd__half--bot .cd__num { top: -100%; }
.cd__shade { position: absolute; inset: 0; background: $stage-ink; opacity: 0; pointer-events: none; }
.cd__leaf { z-index: 2; }
.cd__leaf--bot { transform: rotateX(180deg); }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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #17141a;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd__view {
display: grid;
place-items: center;
}
.cd__cards {
display: flex;
gap: 12px;
--cw: clamp(72px, 20vw, 108px);
}
.cd__card {
position: relative;
width: var(--cw);
height: calc(var(--cw) * 1.4);
transform-style: preserve-3d;
}
.cd__half {
position: absolute;
left: 0;
width: 100%;
height: 50%;
overflow: hidden;
background: #17141a;
color: #fff7e6;
backface-visibility: hidden;
}
.cd__half--top {
top: 0;
border-radius: 8px 8px 0 0;
transform-origin: 50% 100%;
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
}
.cd__half--bot {
bottom: 0;
border-radius: 0 0 8px 8px;
transform-origin: 50% 0;
}
.cd__num {
position: absolute;
left: 0;
width: 100%;
height: 200%;
display: grid;
place-items: center;
font-size: calc(var(--cw) * 0.98);
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.cd__half--top .cd__num {
top: 0;
}
.cd__half--bot .cd__num {
top: -100%;
}
.cd__shade {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.cd__leaf {
z-index: 2;
}
.cd__leaf--bot {
transform: rotateX(180deg);
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const COUNT_FROM = 12;
const DEMO_SECOND_MS = 150;
const SECOND_MS = 1000;
const HOLD_MS = 300;
const FLIP_MS = 140;
const STIFFNESS = 2200;
const SHADE_MAX = 0.6;
const DEPTH_PX = 360;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = COUNT_FROM * secondMs;
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const DAMPING = 2 * 0.55 * Math.sqrt(STIFFNESS);
const cardsEl = q('[data-r="cards"]');
function leaf(cls) {
const el = document.createElement("div");
el.className = cx("cd__half " + cls);
const num = document.createElement("span");
num.className = cx("cd__num");
const shade = document.createElement("i");
shade.className = cx("cd__shade");
el.appendChild(num);
el.appendChild(shade);
return { el, num, shade };
}
function makeCard() {
const el = document.createElement("div");
el.className = cx("cd__card");
el.style.perspective = DEPTH_PX + "px";
const top = leaf("cd__half--top");
const bot = leaf("cd__half--bot");
const leafTop = leaf("cd__half--top cd__leaf cd__leaf--top");
const leafBot = leaf("cd__half--bot cd__leaf cd__leaf--bot");
[top, bot, leafTop, leafBot].forEach((l) => el.appendChild(l.el));
cardsEl.appendChild(el);
return { el, top, bot, leafTop, leafBot, cur: 0, nxt: 0, flipping: false, x: 0, v: 0, age: 0 };
}
const cards = [makeCard(), makeCard()];
function setNums(c, topV, botV, ltV, lbV) {
c.top.num.textContent = String(topV);
c.bot.num.textContent = String(botV);
c.leafTop.num.textContent = String(ltV);
c.leafBot.num.textContent = String(lbV);
}
// 숫자가 바뀌면 접기를 새로 시작한다. 이전 접기가 아직 돌고 있으면 그 끝 값으로 먼저 굳힌다
function setTarget(c, d) {
const shown = c.flipping ? c.nxt : c.cur;
if (shown === d)
return;
if (c.flipping)
c.cur = c.nxt;
c.nxt = d;
if (reduce) {
c.cur = d;
c.flipping = false;
return;
}
c.flipping = true;
c.x = 0;
c.v = 0;
c.age = 0;
}
function place(c) {
if (!c.flipping) {
setNums(c, c.cur, c.cur, c.cur, c.cur);
c.leafTop.el.style.display = "none";
c.leafBot.el.style.display = "none";
c.top.shade.style.opacity = "0";
c.bot.shade.style.opacity = "0";
return;
}
// 접힌 각도 θ (도). 스프링이 1 을 살짝 넘으면 θ 도 180 을 넘어 한 번 튕긴다
const th = clamp(c.x, 0, 1.12) * 180;
const s = SHADE_MAX * Math.abs(Math.sin((th * Math.PI) / 180));
setNums(c, c.nxt, c.cur, c.cur, c.nxt);
c.leafTop.el.style.display = th < 90 ? "block" : "none";
c.leafBot.el.style.display = th >= 90 ? "block" : "none";
c.leafTop.el.style.transform = "rotateX(" + (-th).toFixed(2) + "deg)";
c.leafBot.el.style.transform = "rotateX(" + (180 - th).toFixed(2) + "deg)";
// 두 반쪽과 종이 자신의 그림자 농도 = SHADE_MAX × sin θ
c.top.shade.style.opacity = s.toFixed(3);
c.bot.shade.style.opacity = s.toFixed(3);
c.leafTop.shade.style.opacity = (s * 0.7).toFixed(3);
c.leafBot.shade.style.opacity = (s * 0.7).toFixed(3);
}
function shownCount() {
return Math.ceil(remainMs() / secondMs - 1e-6);
}
function resize() { }
function resetState() {
cards.forEach((c) => {
c.flipping = false;
c.x = 0;
c.v = 0;
});
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = COUNT_FROM * secondMs;
};
function tick(dt) {
const n = shownCount();
setTarget(cards[0], Math.floor(n / 10));
setTarget(cards[1], n % 10);
cards.forEach((c) => {
if (!c.flipping)
return;
c.age += dt * 1000;
let left = dt;
while (left > 0) {
const h = Math.min(left, 1 / 240);
c.v += (-STIFFNESS * (c.x - 1) - DAMPING * c.v) * h;
c.x += c.v * h;
left -= h;
}
if (c.age >= FLIP_MS) {
c.cur = c.nxt;
c.flipping = false;
}
});
}
function draw() {
cards.forEach(place);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// FlipDigitHingeShadow - vanilla 판(02-cd-flip-digit-hinge-shadow) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FlipDigitHingeShadow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FlipDigitHingeShadow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const COUNT_FROM = 12;
const DEMO_SECOND_MS = 150;
const SECOND_MS = 1000;
const HOLD_MS = 300;
const FLIP_MS = 140;
const STIFFNESS = 2200;
const SHADE_MAX = 0.6;
const DEPTH_PX = 360;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = COUNT_FROM * secondMs;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
type Leaf = { el: HTMLElement; num: HTMLElement; shade: HTMLElement };
type Card = {
el: HTMLElement;
top: Leaf;
bot: Leaf;
leafTop: Leaf;
leafBot: Leaf;
cur: number;
nxt: number;
flipping: boolean;
x: number;
v: number;
age: number;
};
const DAMPING = 2 * 0.55 * Math.sqrt(STIFFNESS);
const cardsEl = q<HTMLElement>('[data-r="cards"]');
function leaf(cls: string): Leaf {
const el = document.createElement("div");
el.className = cx("cd__half " + cls);
const num = document.createElement("span");
num.className = cx("cd__num");
const shade = document.createElement("i");
shade.className = cx("cd__shade");
el.appendChild(num);
el.appendChild(shade);
return { el, num, shade };
}
function makeCard(): Card {
const el = document.createElement("div");
el.className = cx("cd__card");
el.style.perspective = DEPTH_PX + "px";
const top = leaf("cd__half--top");
const bot = leaf("cd__half--bot");
const leafTop = leaf("cd__half--top cd__leaf cd__leaf--top");
const leafBot = leaf("cd__half--bot cd__leaf cd__leaf--bot");
[top, bot, leafTop, leafBot].forEach((l) => el.appendChild(l.el));
cardsEl.appendChild(el);
return { el, top, bot, leafTop, leafBot, cur: 0, nxt: 0, flipping: false, x: 0, v: 0, age: 0 };
}
const cards: Card[] = [makeCard(), makeCard()];
function setNums(c: Card, topV: number, botV: number, ltV: number, lbV: number): void {
c.top.num.textContent = String(topV);
c.bot.num.textContent = String(botV);
c.leafTop.num.textContent = String(ltV);
c.leafBot.num.textContent = String(lbV);
}
// 숫자가 바뀌면 접기를 새로 시작한다. 이전 접기가 아직 돌고 있으면 그 끝 값으로 먼저 굳힌다
function setTarget(c: Card, d: number): void {
const shown = c.flipping ? c.nxt : c.cur;
if (shown === d) return;
if (c.flipping) c.cur = c.nxt;
c.nxt = d;
if (reduce) {
c.cur = d;
c.flipping = false;
return;
}
c.flipping = true;
c.x = 0;
c.v = 0;
c.age = 0;
}
function place(c: Card): void {
if (!c.flipping) {
setNums(c, c.cur, c.cur, c.cur, c.cur);
c.leafTop.el.style.display = "none";
c.leafBot.el.style.display = "none";
c.top.shade.style.opacity = "0";
c.bot.shade.style.opacity = "0";
return;
}
// 접힌 각도 θ (도). 스프링이 1 을 살짝 넘으면 θ 도 180 을 넘어 한 번 튕긴다
const th = clamp(c.x, 0, 1.12) * 180;
const s = SHADE_MAX * Math.abs(Math.sin((th * Math.PI) / 180));
setNums(c, c.nxt, c.cur, c.cur, c.nxt);
c.leafTop.el.style.display = th < 90 ? "block" : "none";
c.leafBot.el.style.display = th >= 90 ? "block" : "none";
c.leafTop.el.style.transform = "rotateX(" + (-th).toFixed(2) + "deg)";
c.leafBot.el.style.transform = "rotateX(" + (180 - th).toFixed(2) + "deg)";
// 두 반쪽과 종이 자신의 그림자 농도 = SHADE_MAX × sin θ
c.top.shade.style.opacity = s.toFixed(3);
c.bot.shade.style.opacity = s.toFixed(3);
c.leafTop.shade.style.opacity = (s * 0.7).toFixed(3);
c.leafBot.shade.style.opacity = (s * 0.7).toFixed(3);
}
function shownCount(): number {
return Math.ceil(remainMs() / secondMs - 1e-6);
}
function resize(): void {}
function resetState(): void {
cards.forEach((c) => {
c.flipping = false;
c.x = 0;
c.v = 0;
});
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = COUNT_FROM * secondMs;
};
function tick(dt: number): void {
const n = shownCount();
setTarget(cards[0], Math.floor(n / 10));
setTarget(cards[1], n % 10);
cards.forEach((c) => {
if (!c.flipping) return;
c.age += dt * 1000;
let left = dt;
while (left > 0) {
const h = Math.min(left, 1 / 240);
c.v += (-STIFFNESS * (c.x - 1) - DAMPING * c.v) * h;
c.x += c.v * h;
left -= h;
}
if (c.age >= FLIP_MS) {
c.cur = c.nxt;
c.flipping = false;
}
});
}
function draw(): void {
cards.forEach(place);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view")}>
<div className={cx("cd__cards")} data-r="cards" role="timer" aria-label="남은 시간 카드"></div>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}02 · 접히는 각도에 맞춰 그림자가 따라 도는 플립 숫자 카드 — 측정 누적면적 5.244% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function setTarget(c, d) {
const shown = c.flipping ? c.nxt : c.cur;
if (shown === d)
return;
if (c.flipping)
c.cur = c.nxt;
c.nxt = d;
if (reduce) {
c.cur = d;
c.flipping = false;
return;
}
c.flipping = true;
c.x = 0;
c.v = 0;
c.age = 0;
}
03모래알이 좁은 목을 지나 쌓이며 시간을 재는 모래시계
96×96 셀 격자에서 알갱이는 아래 칸이 비면 떨어지고 막히면 좌우 대각선 아래로 미끄러지며, 목 윗줄은 경과 시간에서 계산한 허용량 allowed 가 남았을 때만 통과시킨다. 목 너비 NECK_W 는 필요 유량(600 알갱이를 1.5초에, 초당 400)을 DROP_RATE 240 으로 나눈 칸 수를 짝수로 올린 2칸이다. 집중 타이머나 보드게임 제한 시간에 쓰고, 면적 3.7%·강도 177.8 에 23/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--scene">
<canvas class="cd__cv" data-r="cv" role="img" aria-label="모래알이 목을 지나 떨어지는 모래시계"></canvas>
<p class="cd__read cd__read--corner" role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd {
--sand: #{mix($stage-yellow, $subject-cream, 60%)};
--sand2: #{mix($subject-pink, $stage-yellow, 40%)};
--glass: #{mix($subject-sky, $subject-cream, 55%)};
}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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd {
--sand: rgb(100%, 88.1568627451%, 50.9019607843%);
--sand2: rgb(100%, 74.1960784314%, 44.1568627451%);
--glass: rgb(68.7254901961%, 86.7254901961%, 95.5882352941%);
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const TOTAL_MS = 1500;
const HOLD_MS = 300;
const GRAIN_COUNT = 600;
const DROP_RATE = 240;
let totalMs = TOTAL_MS;
const cv = q('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d)
return () => { };
const g = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas() {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name) {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h) {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m)
return [255, 255, 255];
const ch = (v, pct) => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c, a) {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const GW = 96;
const GH = 96;
const NECK_TOP = GH / 2 - 1;
// 목 너비(칸 수): 한 칸 열이 초당 DROP_RATE 알갱이를 흘릴 수 있으니, 필요한 유량 = 알갱이 수 / 시간 을 그 값으로 나눠 올림한다
const NECK_W = Math.max(2, 2 * Math.ceil(GRAIN_COUNT / (TOTAL_MS / 1000) / DROP_RATE / 2));
// <sim>
const grid = new Uint8Array(GW * GH);
const wall = new Uint8Array(GW * GH);
let released = 0;
let seed = 12345;
let stepNo = 0;
let acc = 0;
function halfWidth(r) {
const d = Math.abs(r - (GH - 1) / 2) - 0.5;
return NECK_W / 2 + (GW / 2 - 1 - NECK_W / 2) * (d / (GH / 2 - 1));
}
function inside(r, c) {
if (r < 0 || r >= GH || c < 0 || c >= GW)
return false;
return Math.abs(c + 0.5 - GW / 2) <= halfWidth(r);
}
function rand() {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
function fillTop() {
grid.fill(0);
released = 0;
let n = 0;
for (let r = NECK_TOP - 1; r >= 0 && n < GRAIN_COUNT; r--) {
for (let c = 0; c < GW && n < GRAIN_COUNT; c++) {
if (inside(r, c)) {
grid[r * GW + c] = 1;
n++;
}
}
}
return n;
}
// 목 위쪽 줄로 들어가는 알갱이는 허용량 allowed 가 남아 있을 때만 통과한다
function canEnter(r, c, allowed) {
if (!inside(r, c) || grid[r * GW + c] !== 0)
return false;
if (r === NECK_TOP && released >= allowed)
return false;
return true;
}
function move(r, c, r2, c2) {
grid[r * GW + c] = 0;
grid[r2 * GW + c2] = 1;
if (r2 === NECK_TOP)
released++;
}
// 한 스텝: 아래 칸이 비면 떨어지고, 막히면 좌우 대각선 아래로 미끄러진다(안식각)
function step(allowed) {
stepNo++;
for (let r = GH - 2; r >= 0; r--) {
const ltr = ((r + stepNo) & 1) === 0;
for (let k = 0; k < GW; k++) {
const c = ltr ? k : GW - 1 - k;
if (grid[r * GW + c] === 0)
continue;
if (canEnter(r + 1, c, allowed)) {
move(r, c, r + 1, c);
continue;
}
const first = rand() < 0.5 ? -1 : 1;
if (canEnter(r + 1, c + first, allowed) && inside(r, c + first))
move(r, c, r + 1, c + first);
else if (canEnter(r + 1, c - first, allowed) && inside(r, c - first))
move(r, c, r + 1, c - first);
}
}
}
function allowedAt(ms) {
return Math.floor(GRAIN_COUNT * clamp(ms / totalMs, 0, 1) + 1e-6);
}
// </sim>
for (let r = 0; r < GH; r++) {
for (let c = 0; c < GW; c++) {
if (inside(r, c))
continue;
if (inside(r - 1, c) || inside(r + 1, c) || inside(r, c - 1) || inside(r, c + 1))
wall[r * GW + c] = 1;
}
}
const off = document.createElement("canvas");
off.width = GW;
off.height = GH;
const offCtx = off.getContext("2d");
if (!offCtx)
return () => { };
const oc = offCtx;
const img = oc.createImageData(GW, GH);
const num = q('[data-r="num"]');
let sand = [255, 233, 159];
let sandB = [255, 200, 150];
let glass = [182, 216, 255];
function resize() {
fitCanvas();
sand = parseColor(cssVar("--sand"));
sandB = parseColor(cssVar("--sand2"));
glass = parseColor(cssVar("--glass"));
}
function resetState() {
seed = 12345;
stepNo = 0;
acc = 0;
fillTop();
}
function tick(dt) {
const allowed = allowedAt(elapsed);
if (reduce) {
// 움직임 줄이기: 낙하 과정 없이 허용량까지 한꺼번에 굴려 결과 모양만 그린다
for (let i = 0; i < 400 && (released < allowed || i < 30); i++)
step(allowed);
return;
}
acc += dt * DROP_RATE;
let n = Math.floor(acc);
acc -= n;
n = Math.min(n, 12);
for (let i = 0; i < n; i++)
step(allowed);
}
function draw() {
const px = img.data;
for (let r = 0; r < GH; r++) {
for (let c = 0; c < GW; c++) {
const i = r * GW + c;
const o = i * 4;
if (grid[i] === 1) {
const m = ((r * 7 + c * 13) % 5) / 4;
const col = m > 0.5 ? sand : sandB;
const sh = 0.9 + 0.1 * m;
px[o] = col[0] * sh;
px[o + 1] = col[1] * sh;
px[o + 2] = col[2] * sh;
px[o + 3] = 255;
}
else if (wall[i] === 1) {
px[o] = glass[0];
px[o + 1] = glass[1];
px[o + 2] = glass[2];
px[o + 3] = 230;
}
else {
px[o + 3] = 0;
}
}
}
oc.putImageData(img, 0, 0);
g.clearRect(0, 0, cw, ch);
const s = Math.min((cw * 0.92) / GW, (ch - 12) / GH);
const w = GW * s;
const h = GH * s;
const ox = (cw - w) / 2;
const oy = (ch - h) / 2;
g.imageSmoothingEnabled = false;
g.drawImage(off, ox, oy, w, h);
// 유리 뚜껑 두 장
g.fillStyle = rgba(glass, 0.9);
g.fillRect(ox - 2, oy - 5, w + 4, 5);
g.fillRect(ox - 2, oy + h, w + 4, 5);
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// HourglassSandGrains - vanilla 판(03-cd-hourglass-sand-grains) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HourglassSandGrains.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HourglassSandGrains() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const TOTAL_MS = 1500;
const HOLD_MS = 300;
const GRAIN_COUNT = 600;
const DROP_RATE = 240;
let totalMs = TOTAL_MS;
const cv = q<HTMLCanvasElement>('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d) return () => {};
const g: CanvasRenderingContext2D = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas(): void {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name: string): string {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h: string): number[] {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m) return [255, 255, 255];
const ch = (v: string, pct: string): number => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c: number[], a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const GW = 96;
const GH = 96;
const NECK_TOP = GH / 2 - 1;
// 목 너비(칸 수): 한 칸 열이 초당 DROP_RATE 알갱이를 흘릴 수 있으니, 필요한 유량 = 알갱이 수 / 시간 을 그 값으로 나눠 올림한다
const NECK_W = Math.max(2, 2 * Math.ceil(GRAIN_COUNT / (TOTAL_MS / 1000) / DROP_RATE / 2));
// <sim>
const grid = new Uint8Array(GW * GH);
const wall = new Uint8Array(GW * GH);
let released = 0;
let seed = 12345;
let stepNo = 0;
let acc = 0;
function halfWidth(r: number): number {
const d = Math.abs(r - (GH - 1) / 2) - 0.5;
return NECK_W / 2 + (GW / 2 - 1 - NECK_W / 2) * (d / (GH / 2 - 1));
}
function inside(r: number, c: number): boolean {
if (r < 0 || r >= GH || c < 0 || c >= GW) return false;
return Math.abs(c + 0.5 - GW / 2) <= halfWidth(r);
}
function rand(): number {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
function fillTop(): number {
grid.fill(0);
released = 0;
let n = 0;
for (let r = NECK_TOP - 1; r >= 0 && n < GRAIN_COUNT; r--) {
for (let c = 0; c < GW && n < GRAIN_COUNT; c++) {
if (inside(r, c)) {
grid[r * GW + c] = 1;
n++;
}
}
}
return n;
}
// 목 위쪽 줄로 들어가는 알갱이는 허용량 allowed 가 남아 있을 때만 통과한다
function canEnter(r: number, c: number, allowed: number): boolean {
if (!inside(r, c) || grid[r * GW + c] !== 0) return false;
if (r === NECK_TOP && released >= allowed) return false;
return true;
}
function move(r: number, c: number, r2: number, c2: number): void {
grid[r * GW + c] = 0;
grid[r2 * GW + c2] = 1;
if (r2 === NECK_TOP) released++;
}
// 한 스텝: 아래 칸이 비면 떨어지고, 막히면 좌우 대각선 아래로 미끄러진다(안식각)
function step(allowed: number): void {
stepNo++;
for (let r = GH - 2; r >= 0; r--) {
const ltr = ((r + stepNo) & 1) === 0;
for (let k = 0; k < GW; k++) {
const c = ltr ? k : GW - 1 - k;
if (grid[r * GW + c] === 0) continue;
if (canEnter(r + 1, c, allowed)) {
move(r, c, r + 1, c);
continue;
}
const first = rand() < 0.5 ? -1 : 1;
if (canEnter(r + 1, c + first, allowed) && inside(r, c + first)) move(r, c, r + 1, c + first);
else if (canEnter(r + 1, c - first, allowed) && inside(r, c - first)) move(r, c, r + 1, c - first);
}
}
}
function allowedAt(ms: number): number {
return Math.floor(GRAIN_COUNT * clamp(ms / totalMs, 0, 1) + 1e-6);
}
// </sim>
for (let r = 0; r < GH; r++) {
for (let c = 0; c < GW; c++) {
if (inside(r, c)) continue;
if (inside(r - 1, c) || inside(r + 1, c) || inside(r, c - 1) || inside(r, c + 1)) wall[r * GW + c] = 1;
}
}
const off = document.createElement("canvas");
off.width = GW;
off.height = GH;
const offCtx = off.getContext("2d");
if (!offCtx) return () => {};
const oc: CanvasRenderingContext2D = offCtx;
const img = oc.createImageData(GW, GH);
const num = q<HTMLElement>('[data-r="num"]');
let sand = [255, 233, 159];
let sandB = [255, 200, 150];
let glass = [182, 216, 255];
function resize(): void {
fitCanvas();
sand = parseColor(cssVar("--sand"));
sandB = parseColor(cssVar("--sand2"));
glass = parseColor(cssVar("--glass"));
}
function resetState(): void {
seed = 12345;
stepNo = 0;
acc = 0;
fillTop();
}
function tick(dt: number): void {
const allowed = allowedAt(elapsed);
if (reduce) {
// 움직임 줄이기: 낙하 과정 없이 허용량까지 한꺼번에 굴려 결과 모양만 그린다
for (let i = 0; i < 400 && (released < allowed || i < 30); i++) step(allowed);
return;
}
acc += dt * DROP_RATE;
let n = Math.floor(acc);
acc -= n;
n = Math.min(n, 12);
for (let i = 0; i < n; i++) step(allowed);
}
function draw(): void {
const px = img.data;
for (let r = 0; r < GH; r++) {
for (let c = 0; c < GW; c++) {
const i = r * GW + c;
const o = i * 4;
if (grid[i] === 1) {
const m = ((r * 7 + c * 13) % 5) / 4;
const col = m > 0.5 ? sand : sandB;
const sh = 0.9 + 0.1 * m;
px[o] = col[0] * sh;
px[o + 1] = col[1] * sh;
px[o + 2] = col[2] * sh;
px[o + 3] = 255;
} else if (wall[i] === 1) {
px[o] = glass[0];
px[o + 1] = glass[1];
px[o + 2] = glass[2];
px[o + 3] = 230;
} else {
px[o + 3] = 0;
}
}
}
oc.putImageData(img, 0, 0);
g.clearRect(0, 0, cw, ch);
const s = Math.min((cw * 0.92) / GW, (ch - 12) / GH);
const w = GW * s;
const h = GH * s;
const ox = (cw - w) / 2;
const oy = (ch - h) / 2;
g.imageSmoothingEnabled = false;
g.drawImage(off, ox, oy, w, h);
// 유리 뚜껑 두 장
g.fillStyle = rgba(glass, 0.9);
g.fillRect(ox - 2, oy - 5, w + 4, 5);
g.fillRect(ox - 2, oy + h, w + 4, 5);
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--scene")}>
<canvas className={cx("cd__cv")} data-r="cv" role="img" aria-label="모래알이 목을 지나 떨어지는 모래시계"></canvas>
<p className={cx("cd__read cd__read--corner")} role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}03 · 모래알이 좁은 목을 지나 쌓이며 시간을 재는 모래시계 — 측정 누적면적 3.686% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function step(allowed) {
stepNo++;
for (let r = GH - 2; r >= 0; r--) {
const ltr = ((r + stepNo) & 1) === 0;
for (let k = 0; k < GW; k++) {
const c = ltr ? k : GW - 1 - k;
if (grid[r * GW + c] === 0)
continue;
if (canEnter(r + 1, c, allowed)) {
move(r, c, r + 1, c);
continue;
}
const first = rand() < 0.5 ? -1 : 1;
if (canEnter(r + 1, c + first, allowed) && inside(r, c + first))
move(r, c, r + 1, c + first);
else if (canEnter(r + 1, c - first, allowed) && inside(r, c - first))
move(r, c, r + 1, c - first);
}
}
}
function allowedAt(ms) {
return Math.floor(GRAIN_COUNT * clamp(ms / totalMs, 0, 1) + 1e-6);
}
04불붙은 점이 도화선 길이를 따라 걷고 불똥이 튀는 카운트다운
getPointAtLength 를 쓰지 않고 buildPath 가 181점을 직접 샘플링해 점마다 누적 길이 s 와 이웃 두 점 차이로 만든 법선을 두며, 불붙은 길이는 PATH_LEN 262.5 에 지난 비율을 곱한 값이다. 그 점에서 56개짜리 불똥 풀이 법선 방향 ±1.1 라디안 안으로 흩어지고, 이미 탄 구간은 어두운 선, 남은 줄은 점선으로 칠한다. 게임 시작 전 연출이나 퀴즈 제한 시간에 쓰며 면적 57.0% 로 아홉 가운데 가장 넓고, 강도는 32.9 에 23/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--scene">
<canvas class="cd__cv" data-r="cv" role="img" aria-label="도화선을 따라 불이 타들어 가는 장면"></canvas>
<p class="cd__read cd__read--corner" role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd {
--flame: #{mix($stage-yellow, $subject-cream, 60%)};
--hot: #{$subject-pink};
--rope: #{$subject-cream};
--bomb: #{$subject-lilac};
}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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd {
--flame: rgb(100%, 88.1568627451%, 50.9019607843%);
--hot: #ff9ebb;
--rope: #fff7e6;
--bomb: #b9a0ff;
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const BURN_MS = 1500;
const HOLD_MS = 300;
const EMBER_N = 56;
const SPARK_SPREAD = 1.1;
let totalMs = BURN_MS;
const cv = q('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d)
return () => { };
const g = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas() {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name) {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h) {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m)
return [255, 255, 255];
const ch = (v, pct) => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c, a) {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
// 그림 좌표계는 220 × 100. 실제 화면 크기는 맞는 배율로 곱한다
const LW = 220;
const LH = 100;
// 도화선 경로: 곡선 위 점을 직접 샘플링하고 점마다 누적 길이와 법선을 둔다
function buildPath() {
const N = 180;
const pts = [];
let len = 0;
for (let i = 0; i <= N; i++) {
const t = i / N;
const x = 14 + t * 172;
const y = 50 + 30 * Math.sin(t * Math.PI * 3.4) * (1 - 0.2 * t);
if (i > 0)
len += Math.hypot(x - pts[i - 1].x, y - pts[i - 1].y);
pts.push({ x, y, nx: 0, ny: 0, s: len });
}
for (let i = 0; i <= N; i++) {
const a = pts[Math.max(0, i - 1)];
const b = pts[Math.min(N, i + 1)];
const tx = b.x - a.x;
const ty = b.y - a.y;
const m = Math.hypot(tx, ty) || 1;
pts[i].nx = -ty / m;
pts[i].ny = tx / m;
}
return pts;
}
const PATH = buildPath();
const PATH_LEN = PATH[PATH.length - 1].s;
// 길이 s 에 해당하는 점(경로 위 선형 보간)
function pointAt(s) {
const t = clamp(s, 0, PATH_LEN);
let lo = 0;
let hi = PATH.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (PATH[mid].s <= t)
lo = mid;
else
hi = mid;
}
const a = PATH[lo];
const b = PATH[hi];
const f = b.s > a.s ? (t - a.s) / (b.s - a.s) : 0;
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, nx: a.nx + (b.nx - a.nx) * f, ny: a.ny + (b.ny - a.ny) * f, s: t };
}
// 불똥 입자 풀: 고정 길이 배열을 돌려 쓴다
const ex = new Float32Array(EMBER_N);
const ey = new Float32Array(EMBER_N);
const evx = new Float32Array(EMBER_N);
const evy = new Float32Array(EMBER_N);
const elife = new Float32Array(EMBER_N);
const EMBER_LIFE = 0.5;
let seed = 7;
let emitAcc = 0;
let slot = 0;
function rand() {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
let cFlame = [255, 233, 159];
let cHot = [255, 158, 187];
let cRope = [255, 247, 230];
let cBomb = [185, 160, 255];
function resize() {
fitCanvas();
cFlame = parseColor(cssVar("--flame"));
cHot = parseColor(cssVar("--hot"));
cRope = parseColor(cssVar("--rope"));
cBomb = parseColor(cssVar("--bomb"));
}
const num = q('[data-r="num"]');
function burnS() {
return PATH_LEN * (1 - remainMs() / totalMs);
}
function resetState() {
elife.fill(0);
emitAcc = 0;
seed = 7;
}
function emit(p) {
const side = rand() < 0.5 ? -1 : 1;
const th = (rand() - 0.5) * 2 * SPARK_SPREAD;
const c = Math.cos(th);
const sn = Math.sin(th);
const dx = p.nx * side;
const dy = p.ny * side;
const sp = 28 + 60 * rand();
ex[slot] = p.x;
ey[slot] = p.y;
evx[slot] = (dx * c - dy * sn) * sp - 14;
evy[slot] = (dx * sn + dy * c) * sp - 10;
elife[slot] = EMBER_LIFE * (0.6 + 0.4 * rand());
slot = (slot + 1) % EMBER_N;
}
function tick(dt) {
if (reduce)
return;
const p = pointAt(burnS());
if (running && remainMs() > 0) {
emitAcc += dt * (EMBER_N / EMBER_LIFE) * 0.8;
while (emitAcc >= 1) {
emit(p);
emitAcc -= 1;
}
}
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0)
continue;
elife[i] -= dt;
evy[i] += 120 * dt;
evx[i] *= Math.pow(0.9, dt * 60);
ex[i] += evx[i] * dt;
ey[i] += evy[i] * dt;
}
}
function draw() {
const sc = Math.min(cw / LW, ch / LH);
const ox = (cw - LW * sc) / 2;
const oy = (ch - LH * sc) / 2;
g.clearRect(0, 0, cw, ch);
g.save();
g.translate(ox, oy);
g.scale(sc, sc);
const s = burnS();
const p = pointAt(s);
const burning = remainMs() > 0;
// 불빛이 번지는 배경
const gl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 64);
gl.addColorStop(0, rgba(cHot, burning ? 0.42 : 0.12));
gl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = gl;
g.fillRect(-ox / sc, -oy / sc, cw / sc, ch / sc);
g.lineCap = "round";
g.lineJoin = "round";
// 탄 부분(재)
g.strokeStyle = rgba(cRope, 0.2);
g.lineWidth = 2.4;
g.beginPath();
g.moveTo(PATH[0].x, PATH[0].y);
for (let i = 1; i < PATH.length && PATH[i].s <= s; i++)
g.lineTo(PATH[i].x, PATH[i].y);
g.lineTo(p.x, p.y);
g.stroke();
// 남은 도화선
g.strokeStyle = rgba(cRope, 0.95);
g.lineWidth = 3.4;
g.setLineDash([4, 2.4]);
g.beginPath();
g.moveTo(p.x, p.y);
for (let i = 0; i < PATH.length; i++)
if (PATH[i].s > s)
g.lineTo(PATH[i].x, PATH[i].y);
g.stroke();
g.setLineDash([]);
// 폭탄
const end = PATH[PATH.length - 1];
g.fillStyle = rgba(cBomb, 1);
g.beginPath();
g.arc(end.x + 14, end.y, 13, 0, Math.PI * 2);
g.fill();
g.fillStyle = rgba(cRope, 0.35);
g.beginPath();
g.arc(end.x + 10, end.y - 4, 4, 0, Math.PI * 2);
g.fill();
// 불똥
if (!reduce) {
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0)
continue;
const a = clamp(elife[i] / EMBER_LIFE, 0, 1);
g.fillStyle = rgba(a > 0.5 ? cFlame : cHot, a);
g.beginPath();
g.arc(ex[i], ey[i], 0.8 + 1.6 * a, 0, Math.PI * 2);
g.fill();
}
}
// 불꽃 머리
const flick = reduce ? 1 : 1 + 0.18 * Math.sin(elapsed * 0.05) + 0.1 * Math.sin(elapsed * 0.13);
const fl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 9 * flick);
fl.addColorStop(0, rgba([255, 255, 255], 1));
fl.addColorStop(0.4, rgba(cFlame, 0.95));
fl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = fl;
g.beginPath();
g.arc(p.x, p.y, 9 * flick, 0, Math.PI * 2);
g.fill();
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// FuseBurnSparkTrail - vanilla 판(04-cd-fuse-burn-spark-trail) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FuseBurnSparkTrail.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FuseBurnSparkTrail() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const BURN_MS = 1500;
const HOLD_MS = 300;
const EMBER_N = 56;
const SPARK_SPREAD = 1.1;
let totalMs = BURN_MS;
const cv = q<HTMLCanvasElement>('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d) return () => {};
const g: CanvasRenderingContext2D = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas(): void {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name: string): string {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h: string): number[] {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m) return [255, 255, 255];
const ch = (v: string, pct: string): number => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c: number[], a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
// 그림 좌표계는 220 × 100. 실제 화면 크기는 맞는 배율로 곱한다
const LW = 220;
const LH = 100;
type Pt = { x: number; y: number; nx: number; ny: number; s: number };
// 도화선 경로: 곡선 위 점을 직접 샘플링하고 점마다 누적 길이와 법선을 둔다
function buildPath(): Pt[] {
const N = 180;
const pts: Pt[] = [];
let len = 0;
for (let i = 0; i <= N; i++) {
const t = i / N;
const x = 14 + t * 172;
const y = 50 + 30 * Math.sin(t * Math.PI * 3.4) * (1 - 0.2 * t);
if (i > 0) len += Math.hypot(x - pts[i - 1].x, y - pts[i - 1].y);
pts.push({ x, y, nx: 0, ny: 0, s: len });
}
for (let i = 0; i <= N; i++) {
const a = pts[Math.max(0, i - 1)];
const b = pts[Math.min(N, i + 1)];
const tx = b.x - a.x;
const ty = b.y - a.y;
const m = Math.hypot(tx, ty) || 1;
pts[i].nx = -ty / m;
pts[i].ny = tx / m;
}
return pts;
}
const PATH = buildPath();
const PATH_LEN = PATH[PATH.length - 1].s;
// 길이 s 에 해당하는 점(경로 위 선형 보간)
function pointAt(s: number): Pt {
const t = clamp(s, 0, PATH_LEN);
let lo = 0;
let hi = PATH.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (PATH[mid].s <= t) lo = mid;
else hi = mid;
}
const a = PATH[lo];
const b = PATH[hi];
const f = b.s > a.s ? (t - a.s) / (b.s - a.s) : 0;
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, nx: a.nx + (b.nx - a.nx) * f, ny: a.ny + (b.ny - a.ny) * f, s: t };
}
// 불똥 입자 풀: 고정 길이 배열을 돌려 쓴다
const ex = new Float32Array(EMBER_N);
const ey = new Float32Array(EMBER_N);
const evx = new Float32Array(EMBER_N);
const evy = new Float32Array(EMBER_N);
const elife = new Float32Array(EMBER_N);
const EMBER_LIFE = 0.5;
let seed = 7;
let emitAcc = 0;
let slot = 0;
function rand(): number {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
let cFlame = [255, 233, 159];
let cHot = [255, 158, 187];
let cRope = [255, 247, 230];
let cBomb = [185, 160, 255];
function resize(): void {
fitCanvas();
cFlame = parseColor(cssVar("--flame"));
cHot = parseColor(cssVar("--hot"));
cRope = parseColor(cssVar("--rope"));
cBomb = parseColor(cssVar("--bomb"));
}
const num = q<HTMLElement>('[data-r="num"]');
function burnS(): number {
return PATH_LEN * (1 - remainMs() / totalMs);
}
function resetState(): void {
elife.fill(0);
emitAcc = 0;
seed = 7;
}
function emit(p: Pt): void {
const side = rand() < 0.5 ? -1 : 1;
const th = (rand() - 0.5) * 2 * SPARK_SPREAD;
const c = Math.cos(th);
const sn = Math.sin(th);
const dx = p.nx * side;
const dy = p.ny * side;
const sp = 28 + 60 * rand();
ex[slot] = p.x;
ey[slot] = p.y;
evx[slot] = (dx * c - dy * sn) * sp - 14;
evy[slot] = (dx * sn + dy * c) * sp - 10;
elife[slot] = EMBER_LIFE * (0.6 + 0.4 * rand());
slot = (slot + 1) % EMBER_N;
}
function tick(dt: number): void {
if (reduce) return;
const p = pointAt(burnS());
if (running && remainMs() > 0) {
emitAcc += dt * (EMBER_N / EMBER_LIFE) * 0.8;
while (emitAcc >= 1) {
emit(p);
emitAcc -= 1;
}
}
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0) continue;
elife[i] -= dt;
evy[i] += 120 * dt;
evx[i] *= Math.pow(0.9, dt * 60);
ex[i] += evx[i] * dt;
ey[i] += evy[i] * dt;
}
}
function draw(): void {
const sc = Math.min(cw / LW, ch / LH);
const ox = (cw - LW * sc) / 2;
const oy = (ch - LH * sc) / 2;
g.clearRect(0, 0, cw, ch);
g.save();
g.translate(ox, oy);
g.scale(sc, sc);
const s = burnS();
const p = pointAt(s);
const burning = remainMs() > 0;
// 불빛이 번지는 배경
const gl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 64);
gl.addColorStop(0, rgba(cHot, burning ? 0.42 : 0.12));
gl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = gl;
g.fillRect(-ox / sc, -oy / sc, cw / sc, ch / sc);
g.lineCap = "round";
g.lineJoin = "round";
// 탄 부분(재)
g.strokeStyle = rgba(cRope, 0.2);
g.lineWidth = 2.4;
g.beginPath();
g.moveTo(PATH[0].x, PATH[0].y);
for (let i = 1; i < PATH.length && PATH[i].s <= s; i++) g.lineTo(PATH[i].x, PATH[i].y);
g.lineTo(p.x, p.y);
g.stroke();
// 남은 도화선
g.strokeStyle = rgba(cRope, 0.95);
g.lineWidth = 3.4;
g.setLineDash([4, 2.4]);
g.beginPath();
g.moveTo(p.x, p.y);
for (let i = 0; i < PATH.length; i++) if (PATH[i].s > s) g.lineTo(PATH[i].x, PATH[i].y);
g.stroke();
g.setLineDash([]);
// 폭탄
const end = PATH[PATH.length - 1];
g.fillStyle = rgba(cBomb, 1);
g.beginPath();
g.arc(end.x + 14, end.y, 13, 0, Math.PI * 2);
g.fill();
g.fillStyle = rgba(cRope, 0.35);
g.beginPath();
g.arc(end.x + 10, end.y - 4, 4, 0, Math.PI * 2);
g.fill();
// 불똥
if (!reduce) {
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0) continue;
const a = clamp(elife[i] / EMBER_LIFE, 0, 1);
g.fillStyle = rgba(a > 0.5 ? cFlame : cHot, a);
g.beginPath();
g.arc(ex[i], ey[i], 0.8 + 1.6 * a, 0, Math.PI * 2);
g.fill();
}
}
// 불꽃 머리
const flick = reduce ? 1 : 1 + 0.18 * Math.sin(elapsed * 0.05) + 0.1 * Math.sin(elapsed * 0.13);
const fl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 9 * flick);
fl.addColorStop(0, rgba([255, 255, 255], 1));
fl.addColorStop(0.4, rgba(cFlame, 0.95));
fl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = fl;
g.beginPath();
g.arc(p.x, p.y, 9 * flick, 0, Math.PI * 2);
g.fill();
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--scene")}>
<canvas className={cx("cd__cv")} data-r="cv" role="img" aria-label="도화선을 따라 불이 타들어 가는 장면"></canvas>
<p className={cx("cd__read cd__read--corner")} role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}04 · 불붙은 점이 도화선 길이를 따라 걷고 불똥이 튀는 카운트다운 — 측정 누적면적 57.0% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function buildPath() {
const N = 180;
const pts = [];
let len = 0;
for (let i = 0; i <= N; i++) {
const t = i / N;
const x = 14 + t * 172;
const y = 50 + 30 * Math.sin(t * Math.PI * 3.4) * (1 - 0.2 * t);
if (i > 0)
len += Math.hypot(x - pts[i - 1].x, y - pts[i - 1].y);
pts.push({ x, y, nx: 0, ny: 0, s: len });
}
for (let i = 0; i <= N; i++) {
const a = pts[Math.max(0, i - 1)];
const b = pts[Math.min(N, i + 1)];
const tx = b.x - a.x;
const ty = b.y - a.y;
const m = Math.hypot(tx, ty) || 1;
pts[i].nx = -ty / m;
pts[i].ny = tx / m;
}
return pts;
}
05바늘이 눈금마다 한 박자 튕기며 지나온 부채꼴이 채워지는 다이얼
한 눈금이 지날 때마다 바늘 목표를 36° 옮기고, SPRING_K 520·DAMPING 22 의 스프링을 1/240초 단위로 적분해 바늘이 한 박자 튕기게 하며 지난 각도는 conic-gradient 층이 --a 만큼 채운다. SPRING_K·DAMPING 으로 계산한 감쇠비는 0.482 이고 36° 칸마다 6.4°(17.7%) 넘치며 감쇠 주기는 315ms 이므로, 150ms 간격의 시연에서는 바늘이 쉬지 못하고 실제 1000ms 칸에서만 눈금마다 자리를 잡는다. 회의 시간 표시나 운동 인터벌에 맞고, 면적 15.0%·강도 132.7 에 23/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--dial">
<div class="cd__face" data-r="face" role="img" aria-label="눈금마다 튕기는 다이얼">
<div class="cd__sector" data-r="sector"></div>
<div class="cd__needle" data-r="needle"></div>
<i class="cd__hub"></i>
</div>
<p class="cd__read cd__read--center" role="timer"><b data-r="num">10</b><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-paper;
$fg: $subject-ink;
$btn: rgba($subject-ink, .1);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd__view--dial { width: var(--view-h); height: var(--view-h); }
.cd__face {
position: relative;
width: 100%;
height: 100%;
border-radius: 50%;
background: mix($subject-lilac, $stage-paper, 18%);
box-shadow: inset 0 0 0 4px mix($subject-lilac, $stage-paper, 60%);
}
.cd__sector {
position: absolute;
inset: 8%;
border-radius: 50%;
background: conic-gradient(from 0deg, $subject-pink calc(var(--a, 0) * 1deg), transparent 0);
}
.cd__tick { position: absolute; inset: 0; }
.cd__tick::before {
content: "";
position: absolute;
left: calc(50% - 2px);
top: 2%;
width: 4px;
height: 8%;
border-radius: $r-xs;
background: mix($subject-lilac, $subject-ink, 70%);
}
.cd__tick.is-past::before { background: $subject-ink; }
.cd__needle {
position: absolute;
left: calc(50% - 4px);
bottom: 50%;
width: 8px;
height: 40%;
border-radius: $r-xs;
background: $subject-ink;
transform-origin: 50% 100%;
}
.cd__hub { position: absolute; left: calc(50% - 8px); top: calc(50% - 8px); width: 16px; height: 16px; border-radius: 50%; background: $subject-ink; }
.cd__read--center { color: $subject-ink; top: 70%; }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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #17141a;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd__view--dial {
width: var(--view-h);
height: var(--view-h);
}
.cd__face {
position: relative;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgb(95.0588235294%, 93.2941176471%, 100%);
box-shadow: inset 0 0 0 4px #d5c6ff;
}
.cd__sector {
position: absolute;
inset: 8%;
border-radius: 50%;
background: conic-gradient(from 0deg, #ff9ebb calc(var(--a, 0) * 1deg), transparent 0);
}
.cd__tick {
position: absolute;
inset: 0;
}
.cd__tick::before {
content: "";
position: absolute;
left: calc(50% - 2px);
top: 2%;
width: 4px;
height: 8%;
border-radius: 4px;
background: rgb(53.4901960784%, 46.2745098039%, 73.0588235294%);
}
.cd__tick.is-past::before {
background: #17141a;
}
.cd__needle {
position: absolute;
left: calc(50% - 4px);
bottom: 50%;
width: 8px;
height: 40%;
border-radius: 4px;
background: #17141a;
transform-origin: 50% 100%;
}
.cd__hub {
position: absolute;
left: calc(50% - 8px);
top: calc(50% - 8px);
width: 16px;
height: 16px;
border-radius: 50%;
background: #17141a;
}
.cd__read--center {
color: #17141a;
top: 70%;
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const TICK_COUNT = 10;
const DEMO_SECOND_MS = 150;
const SECOND_MS = 1000;
const HOLD_MS = 300;
const SPRING_K = 520;
const DAMPING = 22;
const SECTOR_ALPHA = 0.55;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = TICK_COUNT * secondMs;
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const sector = q('[data-r="sector"]');
const needle = q('[data-r="needle"]');
const face = q('[data-r="face"]');
const num = q('[data-r="num"]');
const STEP_DEG = 360 / TICK_COUNT;
const ticks = [];
for (let i = 0; i < TICK_COUNT; i++) {
const t = document.createElement("i");
t.className = cx("cd__tick");
t.style.transform = "rotate(" + i * STEP_DEG + "deg)";
face.appendChild(t);
ticks.push(t);
}
let angle = 0;
let vel = 0;
let passed = 0;
// 지난 눈금 수: secondMs 가 한 번 지날 때마다 한 칸
function passedTicks() {
return Math.min(TICK_COUNT, Math.floor(elapsed / secondMs + 1e-6));
}
function resize() { }
function resetState() {
angle = 0;
vel = 0;
passed = 0;
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = TICK_COUNT * secondMs;
};
function tick(dt) {
passed = passedTicks();
const target = passed * STEP_DEG;
if (reduce) {
angle = target;
vel = 0;
return;
}
let left = dt;
while (left > 0) {
const h = Math.min(left, 1 / 240);
vel += (-SPRING_K * (angle - target) - DAMPING * vel) * h;
angle += vel * h;
left -= h;
}
}
function draw() {
needle.style.transform = "rotate(" + angle.toFixed(2) + "deg)";
// 지나온 각도까지 부채꼴을 따로 깐다. 바늘이 튕기면 부채꼴 끝도 같이 튕긴다
sector.style.setProperty("--a", clamp(angle, 0, 360).toFixed(2));
sector.style.opacity = String(SECTOR_ALPHA);
ticks.forEach((t, i) => t.classList.toggle(cx("is-past"), i < passed));
num.textContent = String(Math.ceil(remainMs() / secondMs - 1e-6));
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// DialSweepTickOvershoot - vanilla 판(05-cd-dial-sweep-tick-overshoot) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./DialSweepTickOvershoot.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DialSweepTickOvershoot() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const TICK_COUNT = 10;
const DEMO_SECOND_MS = 150;
const SECOND_MS = 1000;
const HOLD_MS = 300;
const SPRING_K = 520;
const DAMPING = 22;
const SECTOR_ALPHA = 0.55;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = TICK_COUNT * secondMs;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const sector = q<HTMLElement>('[data-r="sector"]');
const needle = q<HTMLElement>('[data-r="needle"]');
const face = q<HTMLElement>('[data-r="face"]');
const num = q<HTMLElement>('[data-r="num"]');
const STEP_DEG = 360 / TICK_COUNT;
const ticks: HTMLElement[] = [];
for (let i = 0; i < TICK_COUNT; i++) {
const t = document.createElement("i");
t.className = cx("cd__tick");
t.style.transform = "rotate(" + i * STEP_DEG + "deg)";
face.appendChild(t);
ticks.push(t);
}
let angle = 0;
let vel = 0;
let passed = 0;
// 지난 눈금 수: secondMs 가 한 번 지날 때마다 한 칸
function passedTicks(): number {
return Math.min(TICK_COUNT, Math.floor(elapsed / secondMs + 1e-6));
}
function resize(): void {}
function resetState(): void {
angle = 0;
vel = 0;
passed = 0;
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = TICK_COUNT * secondMs;
};
function tick(dt: number): void {
passed = passedTicks();
const target = passed * STEP_DEG;
if (reduce) {
angle = target;
vel = 0;
return;
}
let left = dt;
while (left > 0) {
const h = Math.min(left, 1 / 240);
vel += (-SPRING_K * (angle - target) - DAMPING * vel) * h;
angle += vel * h;
left -= h;
}
}
function draw(): void {
needle.style.transform = "rotate(" + angle.toFixed(2) + "deg)";
// 지나온 각도까지 부채꼴을 따로 깐다. 바늘이 튕기면 부채꼴 끝도 같이 튕긴다
sector.style.setProperty("--a", clamp(angle, 0, 360).toFixed(2));
sector.style.opacity = String(SECTOR_ALPHA);
ticks.forEach((t, i) => t.classList.toggle(cx("is-past"), i < passed));
num.textContent = String(Math.ceil(remainMs() / secondMs - 1e-6));
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--dial")}>
<div className={cx("cd__face")} data-r="face" role="img" aria-label="눈금마다 튕기는 다이얼">
<div className={cx("cd__sector")} data-r="sector"></div>
<div className={cx("cd__needle")} data-r="needle"></div>
<i className={cx("cd__hub")}></i>
</div>
<p className={cx("cd__read cd__read--center")} role="timer"><b data-r="num">10</b><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}05 · 바늘이 눈금마다 한 박자 튕기며 지나온 부채꼴이 채워지는 다이얼 — 측정 누적면적 15.03% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function tick(dt) {
passed = passedTicks();
const target = passed * STEP_DEG;
if (reduce) {
angle = target;
vel = 0;
return;
}
let left = dt;
while (left > 0) {
const h = Math.min(left, 1 / 240);
vel += (-SPRING_K * (angle - target) - DAMPING * vel) * h;
angle += vel * h;
left -= h;
}
}
06자릿수 띠가 빨리 돌수록 번지고 올림이 줄줄이 이어지는 주행계 숫자
남은 시간을 100분의 1초로 올림한 값의 세 자리를 띠 셋이 나눠 맡고, 띠마다 이동 속도(자리/초)를 프레임마다 재서 BLUR_GAIN 0.05 를 곱한 만큼 번짐을 주며 최대 5px 에서 막는다. 올림은 위 자리일수록 CARRY_DELAY 55ms 씩 늦게 도착해 줄줄이 굴러가고, 0.01초 자리는 초당 약 100자리라 번짐 5px·세로 늘림 1.4배에 붙는 반면 0.1초 자리는 약 10자리라 0.5px 에 그친다. 남은 재고나 세일 종료 초 표시에 어울리며 면적 33.6%·강도 101.1 에 23/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--odo">
<div class="cd__cols" data-r="cols" role="timer" aria-label="남은 시간 주행계"></div>
<p class="cd__unit"><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd__view--odo { display: grid; place-items: center; align-content: center; gap: $sp-2; }
.cd__cols {
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: mix($subject-lilac, $stage-ink, 22%);
box-shadow: inset 0 0 0 2px rgba($subject-cream, .16);
--rowh: clamp(64px, 20vw, 112px);
}
.cd__col {
width: calc(var(--rowh) * .62);
height: var(--rowh);
overflow: hidden;
border-radius: $r-sm;
background: $subject-cream;
color: $subject-ink;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18%, #000 82%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 18%, #000 82%, transparent 100%);
}
.cd__strip { will-change: transform; }
.cd__strip span {
display: grid;
place-items: center;
height: var(--rowh);
font-size: calc(var(--rowh) * .78);
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.cd__dot { color: $subject-cream; font-size: calc(var(--rowh) * .6); font-weight: 800; line-height: 1; align-self: flex-end; padding-bottom: $sp-2; }
.cd__unit { margin: 0; color: $subject-cream; font-size: 14px; font-weight: 700; }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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd__view--odo {
display: grid;
place-items: center;
align-content: center;
gap: 8px;
}
.cd__cols {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px;
border-radius: 16px;
background: rgb(22.9960784314%, 19.9215686275%, 29.9529411765%);
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.16);
--rowh: clamp(64px, 20vw, 112px);
}
.cd__col {
width: calc(var(--rowh) * 0.62);
height: var(--rowh);
overflow: hidden;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18%, #000 82%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 18%, #000 82%, transparent 100%);
}
.cd__strip {
will-change: transform;
}
.cd__strip span {
display: grid;
place-items: center;
height: var(--rowh);
font-size: calc(var(--rowh) * 0.78);
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.cd__dot {
color: #fff7e6;
font-size: calc(var(--rowh) * 0.6);
font-weight: 800;
line-height: 1;
align-self: flex-end;
padding-bottom: 8px;
}
.cd__unit {
margin: 0;
color: #fff7e6;
font-size: 14px;
font-weight: 700;
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const TOTAL_MS = 1500;
const HOLD_MS = 300;
const DIGIT_COUNT = 3;
const SPIN_MS = 70;
const BLUR_GAIN = 0.05;
const CARRY_DELAY = 55;
let totalMs = TOTAL_MS;
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const colsEl = q('[data-r="cols"]');
const cols = [];
// 띠에는 0 부터 9 를 쓰고 이어서 0, 1 을 한 번 더 둔다(12 칸). 한 자리가 아홉에서 영으로 넘어갈 때 끊기지 않게 하는 여분이다
const STRIP = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "1"];
// 왼쪽이 가장 높은 자리(1 단위). 그 바로 뒤에 소수점을 둔다
for (let i = DIGIT_COUNT - 1; i >= 0; i--) {
const win = document.createElement("div");
win.className = cx("cd__col");
const strip = document.createElement("div");
strip.className = cx("cd__strip");
STRIP.forEach((d) => {
const s = document.createElement("span");
s.textContent = d;
strip.appendChild(s);
});
win.appendChild(strip);
colsEl.appendChild(win);
cols[i] = { win, strip, pos: 0, vel: 0 };
if (i === DIGIT_COUNT - 1) {
const dot = document.createElement("span");
dot.className = cx("cd__dot");
dot.textContent = ".";
colsEl.appendChild(dot);
}
}
let rowH = 60;
// 자리 i 가 가리켜야 할 값. 올림은 낮은 자리보다 CARRY_DELAY × i 만큼 늦게 도착한다
function targetOf(i) {
const t = Math.max(0, elapsed - CARRY_DELAY * i);
const cs = Math.ceil(clamp(totalMs - t, 0, totalMs) / 10 - 1e-6);
return Math.floor(cs / Math.pow(10, i));
}
function resize() {
rowH = cols[0].win.clientHeight || 60;
}
function resetState() {
cols.forEach((c, i) => {
c.pos = targetOf(i);
c.vel = 0;
});
}
function tick(dt) {
cols.forEach((c, i) => {
const tv = targetOf(i);
if (reduce || dt <= 0) {
c.pos = tv;
c.vel = 0;
return;
}
const before = c.pos;
c.pos += (tv - c.pos) * (1 - Math.exp((-dt * 1000) / SPIN_MS));
// 띠 속도: 프레임마다 위치 변화를 시간으로 나눠 잰다 (자리 수 / 1000ms)
c.vel = Math.abs(c.pos - before) / dt;
});
}
function draw() {
let spin = 0;
cols.forEach((c) => {
spin += c.vel;
const m = ((((c.pos - 1) % 10) + 10) % 10) + 1;
const blur = Math.min(5, c.vel * BLUR_GAIN);
const stretch = 1 + Math.min(0.5, c.vel * BLUR_GAIN * 0.08);
c.strip.style.transformOrigin = "50% " + (m * rowH + rowH / 2).toFixed(1) + "px";
c.strip.style.transform = "translateY(" + (-m * rowH).toFixed(1) + "px) scaleY(" + stretch.toFixed(3) + ")";
c.strip.style.filter = blur > 0.15 ? "blur(" + blur.toFixed(2) + "px)" : "none";
});
// 띠가 빨리 돌수록 하우징 둘레에 빛이 번진다 (속도 합계 -> 0 ~ 1)
const k = reduce ? 0 : clamp(spin / 150, 0, 1);
colsEl.style.boxShadow = "inset 0 0 0 2px rgba(255,247,230,.16), 0 0 " + (8 + 56 * k).toFixed(1) + "px " + (4 + 22 * k).toFixed(1) + "px rgba(185,160,255," + (0.12 + 0.5 * k).toFixed(3) + ")";
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// OdometerStripMotionBlur - vanilla 판(06-cd-odometer-strip-motion-blur) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./OdometerStripMotionBlur.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function OdometerStripMotionBlur() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const TOTAL_MS = 1500;
const HOLD_MS = 300;
const DIGIT_COUNT = 3;
const SPIN_MS = 70;
const BLUR_GAIN = 0.05;
const CARRY_DELAY = 55;
let totalMs = TOTAL_MS;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
type Col = { win: HTMLElement; strip: HTMLElement; pos: number; vel: number };
const colsEl = q<HTMLElement>('[data-r="cols"]');
const cols: Col[] = [];
// 띠에는 0 부터 9 를 쓰고 이어서 0, 1 을 한 번 더 둔다(12 칸). 한 자리가 아홉에서 영으로 넘어갈 때 끊기지 않게 하는 여분이다
const STRIP: string[] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "1"];
// 왼쪽이 가장 높은 자리(1 단위). 그 바로 뒤에 소수점을 둔다
for (let i = DIGIT_COUNT - 1; i >= 0; i--) {
const win = document.createElement("div");
win.className = cx("cd__col");
const strip = document.createElement("div");
strip.className = cx("cd__strip");
STRIP.forEach((d) => {
const s = document.createElement("span");
s.textContent = d;
strip.appendChild(s);
});
win.appendChild(strip);
colsEl.appendChild(win);
cols[i] = { win, strip, pos: 0, vel: 0 };
if (i === DIGIT_COUNT - 1) {
const dot = document.createElement("span");
dot.className = cx("cd__dot");
dot.textContent = ".";
colsEl.appendChild(dot);
}
}
let rowH = 60;
// 자리 i 가 가리켜야 할 값. 올림은 낮은 자리보다 CARRY_DELAY × i 만큼 늦게 도착한다
function targetOf(i: number): number {
const t = Math.max(0, elapsed - CARRY_DELAY * i);
const cs = Math.ceil(clamp(totalMs - t, 0, totalMs) / 10 - 1e-6);
return Math.floor(cs / Math.pow(10, i));
}
function resize(): void {
rowH = cols[0].win.clientHeight || 60;
}
function resetState(): void {
cols.forEach((c, i) => {
c.pos = targetOf(i);
c.vel = 0;
});
}
function tick(dt: number): void {
cols.forEach((c, i) => {
const tv = targetOf(i);
if (reduce || dt <= 0) {
c.pos = tv;
c.vel = 0;
return;
}
const before = c.pos;
c.pos += (tv - c.pos) * (1 - Math.exp((-dt * 1000) / SPIN_MS));
// 띠 속도: 프레임마다 위치 변화를 시간으로 나눠 잰다 (자리 수 / 1000ms)
c.vel = Math.abs(c.pos - before) / dt;
});
}
function draw(): void {
let spin = 0;
cols.forEach((c) => {
spin += c.vel;
const m = ((((c.pos - 1) % 10) + 10) % 10) + 1;
const blur = Math.min(5, c.vel * BLUR_GAIN);
const stretch = 1 + Math.min(0.5, c.vel * BLUR_GAIN * 0.08);
c.strip.style.transformOrigin = "50% " + (m * rowH + rowH / 2).toFixed(1) + "px";
c.strip.style.transform = "translateY(" + (-m * rowH).toFixed(1) + "px) scaleY(" + stretch.toFixed(3) + ")";
c.strip.style.filter = blur > 0.15 ? "blur(" + blur.toFixed(2) + "px)" : "none";
});
// 띠가 빨리 돌수록 하우징 둘레에 빛이 번진다 (속도 합계 -> 0 ~ 1)
const k = reduce ? 0 : clamp(spin / 150, 0, 1);
colsEl.style.boxShadow = "inset 0 0 0 2px rgba(255,247,230,.16), 0 0 " + (8 + 56 * k).toFixed(1) + "px " + (4 + 22 * k).toFixed(1) + "px rgba(185,160,255," + (0.12 + 0.5 * k).toFixed(3) + ")";
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--odo")}>
<div className={cx("cd__cols")} data-r="cols" role="timer" aria-label="남은 시간 주행계"></div>
<p className={cx("cd__unit")}><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}06 · 자릿수 띠가 빨리 돌수록 번지고 올림이 줄줄이 이어지는 주행계 숫자 — 측정 누적면적 33.637% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function tick(dt) {
cols.forEach((c, i) => {
const tv = targetOf(i);
if (reduce || dt <= 0) {
c.pos = tv;
c.vel = 0;
return;
}
const before = c.pos;
c.pos += (tv - c.pos) * (1 - Math.exp((-dt * 1000) / SPIN_MS));
// 띠 속도: 프레임마다 위치 변화를 시간으로 나눠 잰다 (자리 수 / 1000ms)
c.vel = Math.abs(c.pos - before) / dt;
});
}
07수위가 내려갈수록 출렁이고 멈추면 가라앉는 액체 탱크 타이머
수위는 남은 시간에 선형으로 내려가 MIN_LEVEL 0.16 밑으로는 가지 않고, sloshStep 이 수위가 변한 양에 GAIN 0.6 과 TANK_H 100 을 곱해 진폭을 키운 뒤 프레임마다 SLOSH_DAMP 배로 가라앉히며 AMP_MAX 18 에서 자른다. surfaceDy 가 정상파 네 모드를 겹쳐 수면을 그리고 파도 시계는 멈춤 중에도 흘러서, 멈추면 수위는 서고 파도만 남아 잦아든다. 물 마시기 알림이나 실험 실습 타이머에 쓰고, 면적 26.9%·강도 137.4 에 23/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--scene">
<canvas class="cd__cv" data-r="cv" role="img" aria-label="수위가 내려가며 출렁이는 액체 탱크"></canvas>
<p class="cd__read cd__read--corner" role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd {
--water: #{$subject-sky};
--water2: #{$subject-lilac};
--glass: #{$subject-cream};
}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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd {
--water: #6ec8ff;
--water2: #b9a0ff;
--glass: #fff7e6;
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const DRAIN_MS = 1500;
const HOLD_MS = 300;
const TANK_H = 100;
const WAVE_N = 4;
const SLOSH_DAMP = 0.92;
let totalMs = DRAIN_MS;
const cv = q('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d)
return () => { };
const g = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas() {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name) {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h) {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m)
return [255, 255, 255];
const ch = (v, pct) => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c, a) {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
// 그림 좌표계는 220 × 120. 탱크는 가운데 160 × TANK_H
const LW = 220;
const LH = 120;
const TANK_W = 160;
const MIN_LEVEL = 0.16;
const KICK = 6;
const GAIN = 0.6;
const AMP_MAX = 18;
// <slosh>
let amp = KICK;
let wt = 0;
let prevLevel = 1;
function levelOf(rem) {
return MIN_LEVEL + (1 - MIN_LEVEL) * (rem / totalMs);
}
// 수위가 변한 만큼 진폭을 키우고, 프레임마다 SLOSH_DAMP 배로 가라앉힌다 (60fps 기준 프레임 수로 환산)
function sloshStep(a, dLevel, dt) {
const next = a * Math.pow(SLOSH_DAMP, dt * 60) + GAIN * Math.abs(dLevel) * TANK_H;
return Math.min(AMP_MAX, next);
}
// 정상파 합성: 모드 k 는 sin(kπx/W) 모양에 시간 위상이 따로 돈다
function surfaceDy(x, a, t) {
let y = 0;
for (let k = 1; k <= WAVE_N; k++) {
y += (a / k) * Math.sin((k * Math.PI * x) / TANK_W) * Math.cos(t * (9 + 5 * k) + k * 1.3);
}
return y;
}
// </slosh>
let cWater = [110, 200, 255];
let cWater2 = [185, 160, 255];
let cGlass = [255, 247, 230];
function resize() {
fitCanvas();
cWater = parseColor(cssVar("--water"));
cWater2 = parseColor(cssVar("--water2"));
cGlass = parseColor(cssVar("--glass"));
}
const num = q('[data-r="num"]');
function resetState() {
amp = KICK;
prevLevel = levelOf(remainMs());
}
function tick(dt) {
const lv = levelOf(remainMs());
if (reduce) {
amp = 0;
prevLevel = lv;
return;
}
wt += dt;
amp = sloshStep(amp, dt > 0 ? lv - prevLevel : 0, dt);
prevLevel = lv;
}
function draw() {
const sc = Math.min(cw / LW, ch / LH);
const ox = (cw - LW * sc) / 2;
const oy = (ch - LH * sc) / 2;
g.clearRect(0, 0, cw, ch);
g.save();
g.translate(ox, oy);
g.scale(sc, sc);
const tx = (LW - TANK_W) / 2;
const ty = (LH - TANK_H) / 2;
const lv = levelOf(remainMs());
const base = ty + TANK_H * (1 - lv);
const water = (shift, alpha, c) => {
g.beginPath();
g.moveTo(tx, ty + TANK_H);
for (let i = 0; i <= 40; i++) {
const x = (i / 40) * TANK_W;
g.lineTo(tx + x, base - surfaceDy(x, amp, wt + shift));
}
g.lineTo(tx + TANK_W, ty + TANK_H);
g.closePath();
g.fillStyle = rgba(c, alpha);
g.fill();
};
water(0.9, 0.55, cWater2);
water(0, 0.9, cWater);
// 수면 선
g.strokeStyle = rgba(cGlass, 0.7);
g.lineWidth = 1.2;
g.beginPath();
for (let i = 0; i <= 40; i++) {
const x = (i / 40) * TANK_W;
const y = base - surfaceDy(x, amp, wt);
if (i === 0)
g.moveTo(tx + x, y);
else
g.lineTo(tx + x, y);
}
g.stroke();
// 유리 벽과 눈금
g.strokeStyle = rgba(cGlass, 0.8);
g.lineWidth = 2.4;
g.lineJoin = "round";
g.beginPath();
g.moveTo(tx, ty - 6);
g.lineTo(tx, ty + TANK_H);
g.lineTo(tx + TANK_W, ty + TANK_H);
g.lineTo(tx + TANK_W, ty - 6);
g.stroke();
g.lineWidth = 1;
g.strokeStyle = rgba(cGlass, 0.5);
for (let i = 1; i < 5; i++) {
const y = ty + (TANK_H * i) / 5;
g.beginPath();
g.moveTo(tx + TANK_W + 4, y);
g.lineTo(tx + TANK_W + 12, y);
g.stroke();
}
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// LiquidTankSloshLevel - vanilla 판(07-cd-liquid-tank-slosh-level) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LiquidTankSloshLevel.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LiquidTankSloshLevel() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const DRAIN_MS = 1500;
const HOLD_MS = 300;
const TANK_H = 100;
const WAVE_N = 4;
const SLOSH_DAMP = 0.92;
let totalMs = DRAIN_MS;
const cv = q<HTMLCanvasElement>('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d) return () => {};
const g: CanvasRenderingContext2D = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas(): void {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name: string): string {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h: string): number[] {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m) return [255, 255, 255];
const ch = (v: string, pct: string): number => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c: number[], a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
// 그림 좌표계는 220 × 120. 탱크는 가운데 160 × TANK_H
const LW = 220;
const LH = 120;
const TANK_W = 160;
const MIN_LEVEL = 0.16;
const KICK = 6;
const GAIN = 0.6;
const AMP_MAX = 18;
// <slosh>
let amp = KICK;
let wt = 0;
let prevLevel = 1;
function levelOf(rem: number): number {
return MIN_LEVEL + (1 - MIN_LEVEL) * (rem / totalMs);
}
// 수위가 변한 만큼 진폭을 키우고, 프레임마다 SLOSH_DAMP 배로 가라앉힌다 (60fps 기준 프레임 수로 환산)
function sloshStep(a: number, dLevel: number, dt: number): number {
const next = a * Math.pow(SLOSH_DAMP, dt * 60) + GAIN * Math.abs(dLevel) * TANK_H;
return Math.min(AMP_MAX, next);
}
// 정상파 합성: 모드 k 는 sin(kπx/W) 모양에 시간 위상이 따로 돈다
function surfaceDy(x: number, a: number, t: number): number {
let y = 0;
for (let k = 1; k <= WAVE_N; k++) {
y += (a / k) * Math.sin((k * Math.PI * x) / TANK_W) * Math.cos(t * (9 + 5 * k) + k * 1.3);
}
return y;
}
// </slosh>
let cWater = [110, 200, 255];
let cWater2 = [185, 160, 255];
let cGlass = [255, 247, 230];
function resize(): void {
fitCanvas();
cWater = parseColor(cssVar("--water"));
cWater2 = parseColor(cssVar("--water2"));
cGlass = parseColor(cssVar("--glass"));
}
const num = q<HTMLElement>('[data-r="num"]');
function resetState(): void {
amp = KICK;
prevLevel = levelOf(remainMs());
}
function tick(dt: number): void {
const lv = levelOf(remainMs());
if (reduce) {
amp = 0;
prevLevel = lv;
return;
}
wt += dt;
amp = sloshStep(amp, dt > 0 ? lv - prevLevel : 0, dt);
prevLevel = lv;
}
function draw(): void {
const sc = Math.min(cw / LW, ch / LH);
const ox = (cw - LW * sc) / 2;
const oy = (ch - LH * sc) / 2;
g.clearRect(0, 0, cw, ch);
g.save();
g.translate(ox, oy);
g.scale(sc, sc);
const tx = (LW - TANK_W) / 2;
const ty = (LH - TANK_H) / 2;
const lv = levelOf(remainMs());
const base = ty + TANK_H * (1 - lv);
const water = (shift: number, alpha: number, c: number[]): void => {
g.beginPath();
g.moveTo(tx, ty + TANK_H);
for (let i = 0; i <= 40; i++) {
const x = (i / 40) * TANK_W;
g.lineTo(tx + x, base - surfaceDy(x, amp, wt + shift));
}
g.lineTo(tx + TANK_W, ty + TANK_H);
g.closePath();
g.fillStyle = rgba(c, alpha);
g.fill();
};
water(0.9, 0.55, cWater2);
water(0, 0.9, cWater);
// 수면 선
g.strokeStyle = rgba(cGlass, 0.7);
g.lineWidth = 1.2;
g.beginPath();
for (let i = 0; i <= 40; i++) {
const x = (i / 40) * TANK_W;
const y = base - surfaceDy(x, amp, wt);
if (i === 0) g.moveTo(tx + x, y);
else g.lineTo(tx + x, y);
}
g.stroke();
// 유리 벽과 눈금
g.strokeStyle = rgba(cGlass, 0.8);
g.lineWidth = 2.4;
g.lineJoin = "round";
g.beginPath();
g.moveTo(tx, ty - 6);
g.lineTo(tx, ty + TANK_H);
g.lineTo(tx + TANK_W, ty + TANK_H);
g.lineTo(tx + TANK_W, ty - 6);
g.stroke();
g.lineWidth = 1;
g.strokeStyle = rgba(cGlass, 0.5);
for (let i = 1; i < 5; i++) {
const y = ty + (TANK_H * i) / 5;
g.beginPath();
g.moveTo(tx + TANK_W + 4, y);
g.lineTo(tx + TANK_W + 12, y);
g.stroke();
}
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--scene")}>
<canvas className={cx("cd__cv")} data-r="cv" role="img" aria-label="수위가 내려가며 출렁이는 액체 탱크"></canvas>
<p className={cx("cd__read cd__read--corner")} role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}07 · 수위가 내려갈수록 출렁이고 멈추면 가라앉는 액체 탱크 타이머 — 측정 누적면적 26.906% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function levelOf(rem) {
return MIN_LEVEL + (1 - MIN_LEVEL) * (rem / totalMs);
}
// 수위가 변한 만큼 진폭을 키우고, 프레임마다 SLOSH_DAMP 배로 가라앉힌다 (60fps 기준 프레임 수로 환산)
function sloshStep(a, dLevel, dt) {
const next = a * Math.pow(SLOSH_DAMP, dt * 60) + GAIN * Math.abs(dLevel) * TANK_H;
return Math.min(AMP_MAX, next);
}
// 정상파 합성: 모드 k 는 sin(kπx/W) 모양에 시간 위상이 따로 돈다
function surfaceDy(x, a, t) {
let y = 0;
for (let k = 1; k <= WAVE_N; k++) {
y += (a / k) * Math.sin((k * Math.PI * x) / TANK_W) * Math.cos(t * (9 + 5 * k) + k * 1.3);
}
return y;
}
08꺼진 세그먼트에 잔광이 남는 칠세그먼트 LED 카운트다운
세그먼트마다 밝기 b 를 두고, 켜지는 순간 OVERDRIVE 1.45 로 치솟았다가 approach 로 1 까지 내려앉게 하며, 꺼지면 프레임마다 DECAY 0.84 배로 줄여 형광체가 식는 꼬리를 남긴다. DECAY 로 계산하면 켜진 밝기 1 이 60fps 에서 0.05 밑이 되기까지 17.2프레임, 약 286ms 가 걸리고(0.05 는 계산용 기준이고 소스에는 없다) 켜진 밝기의 합은 판 둘레 box-shadow 로도 번진다. 레트로 전광판이나 가전 표시창에 맞고, 면적 46.8%·강도 43.9 에 23/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--seg">
<div class="cd__digits" data-r="digits" role="timer" aria-label="남은 시간 일곱 세그먼트"></div>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
$lit: $subject-mint;
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd__view--seg { display: grid; place-items: center; }
.cd__digits {
display: flex;
gap: $sp-4;
padding: $sp-4 $sp-6;
border-radius: $r-card;
background: mix($stage-ink, $subject-sky, 90%);
box-shadow: inset 0 0 0 2px rgba($subject-cream, .12);
}
.cd__digit { display: block; width: auto; height: calc(var(--view-h) - 32px); aspect-ratio: 52 / 82; overflow: visible; }
.cd__ghost { fill: rgba($subject-cream, .08); }
.cd__glow { fill: $lit; opacity: 0; }
.cd__lit { fill: $lit; opacity: 0; }
.cd__hot { fill: $subject-cream; opacity: 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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd__view--seg {
display: grid;
place-items: center;
}
.cd__digits {
display: flex;
gap: 16px;
padding: 16px 24px;
border-radius: 16px;
background: rgb(12.431372549%, 14.9019607843%, 19.1764705882%);
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.12);
}
.cd__digit {
display: block;
width: auto;
height: calc(var(--view-h) - 32px);
aspect-ratio: 52/82;
overflow: visible;
}
.cd__ghost {
fill: rgba(255, 247, 230, 0.08);
}
.cd__glow {
fill: #4cd4a6;
opacity: 0;
}
.cd__lit {
fill: #4cd4a6;
opacity: 0;
}
.cd__hot {
fill: #fff7e6;
opacity: 0;
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const COUNT_FROM = 12;
const DEMO_SECOND_MS = 150;
const SECOND_MS = 1000;
const HOLD_MS = 300;
const SEG_ON = 1;
const DECAY = 0.84;
const OVERDRIVE = 1.45;
const GLOW_PX = 5;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = COUNT_FROM * secondMs;
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const SVG_NS = "http://www.w3.org/2000/svg";
const digitsEl = q('[data-r="digits"]');
// 세그먼트 a b c d e f g 가 켜지는 숫자 0 부터 9
const MASKS = ["abcdef", "bc", "abdeg", "abcdg", "bcfg", "acdfg", "acdefg", "abc", "abcdefg", "abcdfg"];
const NAMES = "abcdefg";
// 육각형 하나: 가운데 (cx, cy), 긴 쪽 반길이 L, 반두께 h
function hexPts(cx, cy, L, h, horizontal) {
const p = [[-L, 0], [-L + h, -h], [L - h, -h], [L, 0], [L - h, h], [-L + h, h]];
return p
.map((v) => (horizontal ? [cx + v[0], cy + v[1]] : [cx + v[1], cy + v[0]]))
.map((v) => v[0].toFixed(1) + "," + v[1].toFixed(1))
.join(" ");
}
const GEO = [
[20, 6, true], [34, 20.5, false], [34, 49.5, false], [20, 64, true], [6, 49.5, false], [6, 20.5, false], [20, 35, true],
];
function poly(cls, pts, parent, filter) {
const el = document.createElementNS(SVG_NS, "polygon");
el.setAttribute("class", cls);
el.setAttribute("points", pts);
if (filter)
el.setAttribute("filter", filter);
parent.appendChild(el);
return el;
}
const digits = [];
const blurs = [];
for (let d = 0; d < 2; d++) {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", cx("cd__digit"));
svg.setAttribute("viewBox", "-6 -6 52 82");
svg.setAttribute("aria-hidden", "true");
const defs = document.createElementNS(SVG_NS, "defs");
const flt = document.createElementNS(SVG_NS, "filter");
flt.setAttribute("id", "cd-glow-" + d);
flt.setAttribute("x", "-50%");
flt.setAttribute("y", "-50%");
flt.setAttribute("width", "200%");
flt.setAttribute("height", "200%");
const bl = document.createElementNS(SVG_NS, "feGaussianBlur");
bl.setAttribute("stdDeviation", String(GLOW_PX));
flt.appendChild(bl);
defs.appendChild(flt);
svg.appendChild(defs);
blurs.push(bl);
const segs = [];
GEO.forEach((gm) => {
const pts = hexPts(gm[0], gm[1], 12.4, 3.4, gm[2]);
poly(cx("cd__ghost"), pts, svg);
const glow = poly(cx("cd__glow"), pts, svg, "url(#cd-glow-" + d + ")");
const lit = poly(cx("cd__lit"), pts, svg);
const hot = poly(cx("cd__hot"), pts, svg);
segs.push({ glow, lit, hot, b: 0, on: false });
});
digits.push(segs);
digitsEl.appendChild(svg);
}
function shownCount() {
return Math.ceil(remainMs() / secondMs - 1e-6);
}
function wanted() {
const n = shownCount();
return [Math.floor(n / 10), n % 10];
}
function resize() { }
function resetState() {
digits.forEach((segs) => segs.forEach((s) => {
s.b = 0;
s.on = false;
}));
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = COUNT_FROM * secondMs;
};
// 밝기 b: 켜지는 순간 OVERDRIVE 로 치솟았다 SEG_ON 으로 내려앉고, 꺼지면 매 프레임 DECAY 배로 줄어든다
function tick(dt) {
const w = wanted();
digits.forEach((segs, d) => {
const mask = MASKS[w[d]];
segs.forEach((s, k) => {
const on = mask.indexOf(NAMES.charAt(k)) >= 0;
if (reduce) {
s.b = on ? SEG_ON : 0;
}
else if (on) {
if (!s.on)
s.b = OVERDRIVE;
s.b = approach(s.b, SEG_ON, 0.16, dt);
}
else {
s.b *= Math.pow(DECAY, dt * 60);
if (s.b < 0.004)
s.b = 0;
}
s.on = on;
});
});
}
function draw() {
let total = 0;
digits.forEach((segs) => segs.forEach((s) => {
total += clamp(s.b, 0, 1.4);
const lit = clamp(s.b, 0, 1);
s.lit.style.opacity = lit.toFixed(3);
s.glow.style.opacity = (lit * 0.85).toFixed(3);
s.hot.style.opacity = clamp((s.b - 1) / (OVERDRIVE - 1), 0, 1).toFixed(3);
}));
// 켜진 밝기의 합이 판 전체에 번지는 후광이 된다 (칸 14 개 기준 0 ~ 1)
const k = clamp(total / 14, 0, 1);
digitsEl.style.boxShadow = "inset 0 0 0 2px rgba(255,247,230,.12), 0 0 " + (10 + 50 * k).toFixed(1) + "px " + (2 + 20 * k).toFixed(1) + "px rgba(76,212,166," + (0.1 + 0.45 * k).toFixed(3) + ")";
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// SevenSegmentPhosphorGlow - vanilla 판(08-cd-seven-segment-phosphor-glow) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SevenSegmentPhosphorGlow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SevenSegmentPhosphorGlow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const COUNT_FROM = 12;
const DEMO_SECOND_MS = 150;
const SECOND_MS = 1000;
const HOLD_MS = 300;
const SEG_ON = 1;
const DECAY = 0.84;
const OVERDRIVE = 1.45;
const GLOW_PX = 5;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = COUNT_FROM * secondMs;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
type Seg = { glow: SVGPolygonElement; lit: SVGPolygonElement; hot: SVGPolygonElement; b: number; on: boolean };
const SVG_NS = "http://www.w3.org/2000/svg";
const digitsEl = q<HTMLElement>('[data-r="digits"]');
// 세그먼트 a b c d e f g 가 켜지는 숫자 0 부터 9
const MASKS: string[] = ["abcdef", "bc", "abdeg", "abcdg", "bcfg", "acdfg", "acdefg", "abc", "abcdefg", "abcdfg"];
const NAMES = "abcdefg";
// 육각형 하나: 가운데 (cx, cy), 긴 쪽 반길이 L, 반두께 h
function hexPts(cx: number, cy: number, L: number, h: number, horizontal: boolean): string {
const p: number[][] = [[-L, 0], [-L + h, -h], [L - h, -h], [L, 0], [L - h, h], [-L + h, h]];
return p
.map((v) => (horizontal ? [cx + v[0], cy + v[1]] : [cx + v[1], cy + v[0]]))
.map((v) => v[0].toFixed(1) + "," + v[1].toFixed(1))
.join(" ");
}
const GEO: Array<[number, number, boolean]> = [
[20, 6, true], [34, 20.5, false], [34, 49.5, false], [20, 64, true], [6, 49.5, false], [6, 20.5, false], [20, 35, true],
];
function poly(cls: string, pts: string, parent: SVGElement, filter?: string): SVGPolygonElement {
const el = document.createElementNS(SVG_NS, "polygon") as SVGPolygonElement;
el.setAttribute("class", cls);
el.setAttribute("points", pts);
if (filter) el.setAttribute("filter", filter);
parent.appendChild(el);
return el;
}
const digits: Seg[][] = [];
const blurs: SVGElement[] = [];
for (let d = 0; d < 2; d++) {
const svg = document.createElementNS(SVG_NS, "svg") as SVGElement;
svg.setAttribute("class", cx("cd__digit"));
svg.setAttribute("viewBox", "-6 -6 52 82");
svg.setAttribute("aria-hidden", "true");
const defs = document.createElementNS(SVG_NS, "defs");
const flt = document.createElementNS(SVG_NS, "filter");
flt.setAttribute("id", "cd-glow-" + d);
flt.setAttribute("x", "-50%");
flt.setAttribute("y", "-50%");
flt.setAttribute("width", "200%");
flt.setAttribute("height", "200%");
const bl = document.createElementNS(SVG_NS, "feGaussianBlur") as SVGElement;
bl.setAttribute("stdDeviation", String(GLOW_PX));
flt.appendChild(bl);
defs.appendChild(flt);
svg.appendChild(defs);
blurs.push(bl);
const segs: Seg[] = [];
GEO.forEach((gm) => {
const pts = hexPts(gm[0], gm[1], 12.4, 3.4, gm[2]);
poly(cx("cd__ghost"), pts, svg);
const glow = poly(cx("cd__glow"), pts, svg, "url(#cd-glow-" + d + ")");
const lit = poly(cx("cd__lit"), pts, svg);
const hot = poly(cx("cd__hot"), pts, svg);
segs.push({ glow, lit, hot, b: 0, on: false });
});
digits.push(segs);
digitsEl.appendChild(svg);
}
function shownCount(): number {
return Math.ceil(remainMs() / secondMs - 1e-6);
}
function wanted(): number[] {
const n = shownCount();
return [Math.floor(n / 10), n % 10];
}
function resize(): void {}
function resetState(): void {
digits.forEach((segs) =>
segs.forEach((s) => {
s.b = 0;
s.on = false;
})
);
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = COUNT_FROM * secondMs;
};
// 밝기 b: 켜지는 순간 OVERDRIVE 로 치솟았다 SEG_ON 으로 내려앉고, 꺼지면 매 프레임 DECAY 배로 줄어든다
function tick(dt: number): void {
const w = wanted();
digits.forEach((segs, d) => {
const mask = MASKS[w[d]];
segs.forEach((s, k) => {
const on = mask.indexOf(NAMES.charAt(k)) >= 0;
if (reduce) {
s.b = on ? SEG_ON : 0;
} else if (on) {
if (!s.on) s.b = OVERDRIVE;
s.b = approach(s.b, SEG_ON, 0.16, dt);
} else {
s.b *= Math.pow(DECAY, dt * 60);
if (s.b < 0.004) s.b = 0;
}
s.on = on;
});
});
}
function draw(): void {
let total = 0;
digits.forEach((segs) =>
segs.forEach((s) => {
total += clamp(s.b, 0, 1.4);
const lit = clamp(s.b, 0, 1);
s.lit.style.opacity = lit.toFixed(3);
s.glow.style.opacity = (lit * 0.85).toFixed(3);
s.hot.style.opacity = clamp((s.b - 1) / (OVERDRIVE - 1), 0, 1).toFixed(3);
})
);
// 켜진 밝기의 합이 판 전체에 번지는 후광이 된다 (칸 14 개 기준 0 ~ 1)
const k = clamp(total / 14, 0, 1);
digitsEl.style.boxShadow = "inset 0 0 0 2px rgba(255,247,230,.12), 0 0 " + (10 + 50 * k).toFixed(1) + "px " + (2 + 20 * k).toFixed(1) + "px rgba(76,212,166," + (0.1 + 0.45 * k).toFixed(3) + ")";
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--seg")}>
<div className={cx("cd__digits")} data-r="digits" role="timer" aria-label="남은 시간 일곱 세그먼트"></div>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}08 · 꺼진 세그먼트에 잔광이 남는 칠세그먼트 LED 카운트다운 — 측정 누적면적 46.838% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 밝기 b: 켜지는 순간 OVERDRIVE 로 치솟았다 SEG_ON 으로 내려앉고, 꺼지면 매 프레임 DECAY 배로 줄어든다
function tick(dt) {
const w = wanted();
digits.forEach((segs, d) => {
const mask = MASKS[w[d]];
segs.forEach((s, k) => {
const on = mask.indexOf(NAMES.charAt(k)) >= 0;
if (reduce) {
s.b = on ? SEG_ON : 0;
}
else if (on) {
if (!s.on)
s.b = OVERDRIVE;
s.b = approach(s.b, SEG_ON, 0.16, dt);
}
else {
s.b *= Math.pow(DECAY, dt * 60);
if (s.b < 0.004)
s.b = 0;
}
s.on = on;
});
});
}
093 2 1 마다 충격파가 퍼지고 0 에서 점화되는 발사 카운트다운 장면
숫자가 바뀔 때마다 WAVE_R 120 까지 커지며 옅어지는 충격파 링을 시간에서 바로 계산하고, 화면은 vnoise 값 노이즈로 SHAKE_AMP 5px 를 기준 삼아 흔든다. 0 이 되는 순간 tick 이 별 속도 목표를 40 의 STAR_BOOST 3 배인 120 으로 올리고, 속도는 프레임당 12% 씩 다가가 약 300ms 에 90% 에 닿는다. 제품 발표 랜딩이나 오픈 이벤트 히어로에 쓰고, 면적 47.1%·강도 82.1 에 23/23 프레임이 움직였다.

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--scene">
<canvas class="cd__cv" data-r="cv" role="img" aria-label="숫자가 줄다 0 에서 별이 빨라지는 발사 카운트다운"></canvas>
<p class="cd__read cd__read--corner" role="timer"><b data-r="num">1.4</b><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd {
--star: #{$subject-cream};
--ring: #{$subject-sky};
--flame: #{$subject-pink};
--warm: #{mix($stage-yellow, $subject-cream, 60%)};
}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);
}
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd {
--star: #fff7e6;
--ring: #6ec8ff;
--flame: #ff9ebb;
--warm: rgb(100%, 88.1568627451%, 50.9019607843%);
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const COUNT_FROM = 3;
const DEMO_SECOND_MS = 450;
const SECOND_MS = 1000;
const HOLD_MS = 450;
const WAVE_R = 120;
const WAVE_MS = 420;
const SHAKE_AMP = 5;
const STAR_BOOST = 3;
const STAR_N = 90;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = COUNT_FROM * secondMs;
const cv = q('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d)
return () => { };
const g = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas() {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name) {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h) {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m)
return [255, 255, 255];
const ch = (v, pct) => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c, a) {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const sx = new Float32Array(STAR_N);
const sy = new Float32Array(STAR_N);
const sz = new Float32Array(STAR_N);
let seed = 3;
function rand() {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
for (let i = 0; i < STAR_N; i++) {
sx[i] = rand();
sy[i] = rand();
sz[i] = 0.3 + 0.7 * rand();
}
let speed = 40;
const num = q('[data-r="num"]');
// 1차원 값 노이즈: 정수 격자마다 사인으로 만든 값을 두고 사이를 부드럽게 잇는다
function lat(i) {
const s = Math.sin(i * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
function vnoise(x) {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lat(i) * (1 - u) + lat(i + 1) * u - 0.5;
}
let cStar = [255, 247, 230];
let cRing = [110, 200, 255];
let cFlame = [255, 158, 187];
let cWarm = [255, 233, 159];
function resize() {
fitCanvas();
cStar = parseColor(cssVar("--star"));
cRing = parseColor(cssVar("--ring"));
cFlame = parseColor(cssVar("--flame"));
cWarm = parseColor(cssVar("--warm"));
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = COUNT_FROM * secondMs;
};
function shownCount() {
return Math.ceil(remainMs() / secondMs - 1e-6);
}
function resetState() {
speed = 40;
}
function tick(dt) {
if (reduce)
return;
const ignited = shownCount() === 0;
const target = 40 * (ignited ? STAR_BOOST : 1);
speed = approach(speed, target, 0.12, dt);
for (let i = 0; i < STAR_N; i++) {
sy[i] += (speed * sz[i] * dt) / 100;
if (sy[i] > 1.05) {
sy[i] -= 1.1;
sx[i] = rand();
}
}
}
function draw() {
const n = shownCount();
const ignited = n === 0;
// 흔들림: 숫자가 바뀐 직후의 한 방 + 0 에 가까울수록 커지는 바닥 진동
let amp = 0;
if (!reduce) {
const since = elapsed - (COUNT_FROM - n) * secondMs;
const base = Math.pow(1 - n / COUNT_FROM, 2) * 0.45;
amp = SHAKE_AMP * (ignited ? 1 : base + 0.8 * Math.exp(-Math.max(0, since) / 70));
}
g.clearRect(0, 0, cw, ch);
g.save();
if (amp > 0)
g.translate(vnoise(elapsed * 0.07) * 2 * amp, vnoise(elapsed * 0.07 + 40) * 2 * amp);
// 별: 속도에 비례해 길어지는 줄
const trail = reduce ? 0 : speed / 70;
for (let i = 0; i < STAR_N; i++) {
const x = sx[i] * cw;
const y = sy[i] * ch;
g.strokeStyle = rgba(cStar, 0.35 + 0.55 * sz[i]);
g.lineWidth = 1 + sz[i];
g.beginPath();
g.moveTo(x, y);
g.lineTo(x, y - trail * sz[i] * 14);
g.stroke();
if (reduce) {
g.fillStyle = rgba(cStar, 0.6);
g.fillRect(x, y, 1.6, 1.6);
}
}
// 점화 불꽃(아래쪽)
if (ignited) {
const fx = cw / 2;
const fy = ch;
const pulse = reduce ? 1 : 1 + 0.12 * Math.sin(elapsed * 0.09);
const fg = g.createRadialGradient(fx, fy, 0, fx, fy, ch * 0.9 * pulse);
fg.addColorStop(0, rgba(cWarm, 0.9));
fg.addColorStop(0.45, rgba(cFlame, 0.45));
fg.addColorStop(1, rgba(cFlame, 0));
g.fillStyle = fg;
g.fillRect(0, 0, cw, ch);
}
// 충격파 링: 숫자가 바뀐 시각마다 한 개씩, 시간에서 바로 계산한다
if (!reduce) {
const k = Math.min(COUNT_FROM, Math.floor(elapsed / secondMs + 1e-6));
for (let j = Math.max(0, k - 1); j <= k; j++) {
const age = elapsed - j * secondMs;
if (age < 0 || age >= WAVE_MS)
continue;
const u = age / WAVE_MS;
const big = j === COUNT_FROM ? 1.7 : 1;
g.strokeStyle = rgba(j === COUNT_FROM ? cFlame : cRing, Math.pow(1 - u, 1.5) * 0.9);
g.lineWidth = 3 + 5 * (1 - u);
g.beginPath();
g.arc(cw / 2, ch / 2, WAVE_R * big * (1 - Math.pow(1 - u, 3)) * Math.min(1, ch / 200), 0, Math.PI * 2);
g.stroke();
}
}
// 큰 숫자
g.fillStyle = rgba(ignited ? cFlame : cStar, 1);
g.font = "800 " + Math.round(ch * 0.5) + "px -apple-system, Segoe UI, sans-serif";
g.textAlign = "center";
g.textBaseline = "middle";
g.fillText(String(n), cw / 2, ch / 2);
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// LaunchSequenceShockwaveHero - vanilla 판(09-cd-launch-sequence-shockwave-hero) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LaunchSequenceShockwaveHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LaunchSequenceShockwaveHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const COUNT_FROM = 3;
const DEMO_SECOND_MS = 450;
const SECOND_MS = 1000;
const HOLD_MS = 450;
const WAVE_R = 120;
const WAVE_MS = 420;
const SHAKE_AMP = 5;
const STAR_BOOST = 3;
const STAR_N = 90;
let secondMs = demo ? DEMO_SECOND_MS : SECOND_MS;
let totalMs = COUNT_FROM * secondMs;
const cv = q<HTMLCanvasElement>('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d) return () => {};
const g: CanvasRenderingContext2D = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas(): void {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name: string): string {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h: string): number[] {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m) return [255, 255, 255];
const ch = (v: string, pct: string): number => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c: number[], a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
const sx = new Float32Array(STAR_N);
const sy = new Float32Array(STAR_N);
const sz = new Float32Array(STAR_N);
let seed = 3;
function rand(): number {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
for (let i = 0; i < STAR_N; i++) {
sx[i] = rand();
sy[i] = rand();
sz[i] = 0.3 + 0.7 * rand();
}
let speed = 40;
const num = q<HTMLElement>('[data-r="num"]');
// 1차원 값 노이즈: 정수 격자마다 사인으로 만든 값을 두고 사이를 부드럽게 잇는다
function lat(i: number): number {
const s = Math.sin(i * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
function vnoise(x: number): number {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lat(i) * (1 - u) + lat(i + 1) * u - 0.5;
}
let cStar = [255, 247, 230];
let cRing = [110, 200, 255];
let cFlame = [255, 158, 187];
let cWarm = [255, 233, 159];
function resize(): void {
fitCanvas();
cStar = parseColor(cssVar("--star"));
cRing = parseColor(cssVar("--ring"));
cFlame = parseColor(cssVar("--flame"));
cWarm = parseColor(cssVar("--warm"));
}
hooks.stop = () => {
elapsed *= SECOND_MS / secondMs;
secondMs = SECOND_MS;
totalMs = COUNT_FROM * secondMs;
};
function shownCount(): number {
return Math.ceil(remainMs() / secondMs - 1e-6);
}
function resetState(): void {
speed = 40;
}
function tick(dt: number): void {
if (reduce) return;
const ignited = shownCount() === 0;
const target = 40 * (ignited ? STAR_BOOST : 1);
speed = approach(speed, target, 0.12, dt);
for (let i = 0; i < STAR_N; i++) {
sy[i] += (speed * sz[i] * dt) / 100;
if (sy[i] > 1.05) {
sy[i] -= 1.1;
sx[i] = rand();
}
}
}
function draw(): void {
const n = shownCount();
const ignited = n === 0;
// 흔들림: 숫자가 바뀐 직후의 한 방 + 0 에 가까울수록 커지는 바닥 진동
let amp = 0;
if (!reduce) {
const since = elapsed - (COUNT_FROM - n) * secondMs;
const base = Math.pow(1 - n / COUNT_FROM, 2) * 0.45;
amp = SHAKE_AMP * (ignited ? 1 : base + 0.8 * Math.exp(-Math.max(0, since) / 70));
}
g.clearRect(0, 0, cw, ch);
g.save();
if (amp > 0) g.translate(vnoise(elapsed * 0.07) * 2 * amp, vnoise(elapsed * 0.07 + 40) * 2 * amp);
// 별: 속도에 비례해 길어지는 줄
const trail = reduce ? 0 : speed / 70;
for (let i = 0; i < STAR_N; i++) {
const x = sx[i] * cw;
const y = sy[i] * ch;
g.strokeStyle = rgba(cStar, 0.35 + 0.55 * sz[i]);
g.lineWidth = 1 + sz[i];
g.beginPath();
g.moveTo(x, y);
g.lineTo(x, y - trail * sz[i] * 14);
g.stroke();
if (reduce) {
g.fillStyle = rgba(cStar, 0.6);
g.fillRect(x, y, 1.6, 1.6);
}
}
// 점화 불꽃(아래쪽)
if (ignited) {
const fx = cw / 2;
const fy = ch;
const pulse = reduce ? 1 : 1 + 0.12 * Math.sin(elapsed * 0.09);
const fg = g.createRadialGradient(fx, fy, 0, fx, fy, ch * 0.9 * pulse);
fg.addColorStop(0, rgba(cWarm, 0.9));
fg.addColorStop(0.45, rgba(cFlame, 0.45));
fg.addColorStop(1, rgba(cFlame, 0));
g.fillStyle = fg;
g.fillRect(0, 0, cw, ch);
}
// 충격파 링: 숫자가 바뀐 시각마다 한 개씩, 시간에서 바로 계산한다
if (!reduce) {
const k = Math.min(COUNT_FROM, Math.floor(elapsed / secondMs + 1e-6));
for (let j = Math.max(0, k - 1); j <= k; j++) {
const age = elapsed - j * secondMs;
if (age < 0 || age >= WAVE_MS) continue;
const u = age / WAVE_MS;
const big = j === COUNT_FROM ? 1.7 : 1;
g.strokeStyle = rgba(j === COUNT_FROM ? cFlame : cRing, Math.pow(1 - u, 1.5) * 0.9);
g.lineWidth = 3 + 5 * (1 - u);
g.beginPath();
g.arc(cw / 2, ch / 2, WAVE_R * big * (1 - Math.pow(1 - u, 3)) * Math.min(1, ch / 200), 0, Math.PI * 2);
g.stroke();
}
}
// 큰 숫자
g.fillStyle = rgba(ignited ? cFlame : cStar, 1);
g.font = "800 " + Math.round(ch * 0.5) + "px -apple-system, Segoe UI, sans-serif";
g.textAlign = "center";
g.textBaseline = "middle";
g.fillText(String(n), cw / 2, ch / 2);
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--scene")}>
<canvas className={cx("cd__cv")} data-r="cv" role="img" aria-label="숫자가 줄다 0 에서 별이 빨라지는 발사 카운트다운"></canvas>
<p className={cx("cd__read cd__read--corner")} role="timer"><b data-r="num">1.4</b><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}09 · 3 2 1 마다 충격파가 퍼지고 0 에서 점화되는 발사 카운트다운 장면 — 측정 누적면적 47.081% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function shownCount() {
return Math.ceil(remainMs() / secondMs - 1e-6);
}
function resetState() {
speed = 40;
}
function tick(dt) {
if (reduce)
return;
const ignited = shownCount() === 0;
const target = 40 * (ignited ? STAR_BOOST : 1);
speed = approach(speed, target, 0.12, dt);
for (let i = 0; i < STAR_N; i++) {
sy[i] += (speed * sz[i] * dt) / 100;
if (sy[i] > 1.05) {
sy[i] -= 1.1;
sx[i] = rand();
}
}
}
값이 타이머의 인상을 바꾸는 곳 — 전체 시간·알갱이 수·출렁임 감쇠
이 타이머들에서 값 하나가 인상을 가장 크게 바꾸는 자리는 셋이다. 아래 숫자는 소스의 상수와 sloshStep 같은 함수를 노드에서 직접 불러 센 값이라, 화면을 눈대중으로 읽은 값이 아니다.
전체 시간을 절반으로 줄이면 박동이 한 번만 든다
01 의 TOTAL_MS 를 1500 에서 750 으로 줄이면 호가 줄어드는 속도는 둘레 301.6 기준 초당 201 에서 402 가 된다. 박동이 도는 마지막 구간은 600ms 에서 300ms 가 되는데 박동 주기 PULSE_MS 는 300 으로 고정이라 박동이 두 번에서 한 번으로 준다. 색은 같은 비율에서 같은 색을 지나지만 민트에서 분홍까지 가는 시간이 절반이다.
알갱이를 늘리면 목이 넓어지고 더미가 높아진다
03 의 GRAIN_COUNT 를 600 에서 1400 으로 올리면 필요 유량이 초당 400 에서 933 알갱이가 되어 목 너비가 2칸에서 4칸으로 계산되고, 아래 더미는 27줄에서 36줄로 자란다. 마지막 알갱이가 목 윗줄을 지나는 시각은 1504ms 에서 1533ms 로 늦어질 뿐이라 전체 시간 1500ms 에 거의 그대로 맞는다.
감쇠 계수를 올리면 파도가 상한 가까이 붙고 오래 간다
07 의 SLOSH_DAMP 를 0.92 에서 0.97 로 올리면 내려가는 동안의 진폭이 7.0 에서 17.9 로 커져 상한 18 바로 아래에 닿고, 수면 최대 편차는 11 에서 27 이 된다. 멈춘 뒤 진폭이 5% 밑으로 가라앉는 시간은 600ms 에서 1650ms 로 길어진다.
어디서 깨지는가 — 탭이 멈출 때·0 이하로 내려갈 때·움직임 줄이기 설정
탭이 멈췄다 돌아오면 시계는 건너뛰고 물리는 한 걸음만 간다
elapsed 는 돌아온 프레임의 간격을 통째로 더하므로 남은 시간은 곧바로 맞는 값으로 건너뛴다. 대신 물리 한 걸음은 Math.min(raw, 50)/1000 으로 잘라 한 번에 튀지 않게 하고, 02 는 접힘이 한 번만 돌고 숫자는 최종값으로 간다.
03 의 모래는 시계보다 늦어질 수 있다
모래는 600 알갱이에서 허용 시각보다 4ms, 1400 알갱이에서 33ms 늦게 목 윗줄을 지나고, 이 값과 아래 수용량은 노드에서 소스 구간을 직접 불러 센 것이다. 프레임이 20fps 밑으로 내려가면 한 프레임 최대 12스텝 제한과 50ms 제한 때문에 모래가 시계보다 뒤처지고, 알갱이 수가 위 방 수용량(목 2칸에서 2302, 4칸에서 2348)을 넘으면 허용량이 실제 알갱이 수보다 커진다. 격자는 96×96 고정이라 창 크기가 변해도 해상도는 그대로다.
두 자리와 세 자리를 넘는 시간은 자리가 모자란다
08 은 COUNT_FROM 이 100 이상이면 MASKS[10] 이 undefined 라 mask.indexOf 에서 오류가 나고 두 칸으로 99 까지만 표시한다. 06 은 남은 시간이 9.99초를 넘으면 1초 자리 위가 없어 자리가 하나 모자라니 DIGIT_COUNT 와 targetOf 의 자릿수 나눗셈을 같이 고쳐야 한다. 07 은 MIN_LEVEL 0.16 때문에 시간이 0 이어도 물이 16% 남는다.
시연 배속과 실제 1초 칸은 박자가 다르다
05 의 시연 눈금 간격 150ms 는 감쇠 주기 315ms 의 절반이라 바늘이 쉬지 못하는데, 실제 모드에서만 눈금마다 가라앉는다. 09 는 WAVE_MS 420 이 시연 칸 450ms 보다 짧아 링이 한 개씩 보이고, 실제 1000ms 칸에서는 링이 지난 뒤 580ms 가 비며, 끝난 뒤에도 별은 120 으로 계속 달린다. 02 는 FLIP_MS 140 이 칸 시간 150ms 와 10ms 차이뿐이라 칸 시간을 더 줄이면 접힘이 끝나기 전에 다음 숫자가 오고, 숫자를 건너뛴 것처럼 보인다.
상한과 풀 크기가 먼저 걸리는 값들
06 의 BLUR_GAIN 을 올려도 번짐은 5px 에서 막힌다. 07 의 진폭은 AMP_MAX 18 에서 잘려 감쇠 0.97 보다 더 올리면 파도 꼭대기가 평평해진다. 04 의 불똥은 56개 고정 풀이라 방출률이 EMBER_N 보다 높아지면 살아 있는 입자를 덮어쓰고, 멈춤 중에는 새 불똥이 나오지 않지만 떠 있던 것은 끝까지 떨어진다.
움직임 줄이기에서는 정지한 채 시작하고 그리기는 250ms 마다 한 번이다
prefers-reduced-motion: reduce 를 켜면 아홉 장면이 정지한 채 시작하고, 사용자가 시작하면 시간은 흐르되 그리기는 ts - lastDraw < 250 이면 건너뛴다. 02 는 접지 않고 숫자만 교체하고, 03 은 낙하 없이 허용량까지 step 을 한꺼번에 굴린 모양을 보이며, 04·09 는 입자와 충격파를 그리지 않는다. 05·06 은 바늘과 띠가 목표 위치로 바로 가고, 07 은 진폭이 0 이라 수면이 평평하며, 08 은 밝기가 켜짐 1 아니면 꺼짐 0 이다. 브라우저에서 이 설정을 켜고 시작 1.4초 뒤에 읽은 값은 01·03·04·07 이 1.5초에서 0.3초, 05 가 10에서 9, 09 가 3.0에서 1.8, 02 가 12에서 11 이었고, 06·08 은 글자가 아니라 띠 위치와 SVG 밝기로 그려져 화면 글자로는 읽지 못해 소스 줄(pos = tv, b = on ? 1 : 0)로만 확인했다.
직접 고쳐 보며 경계를 확인하는 법
01 의 TOTAL_MS, 03 의 GRAIN_COUNT, 07 의 SLOSH_DAMP 를 하나씩 바꿔 돌리면 위 숫자가 어디서 바뀌는지 손으로 확인된다. 내려받는 압축 파일은 pu69hb35 을 입력하면 풀리고, 안에는 vanilla/ 와 react/ 폴더, 그리고 prompt.md·README.md 가 들어 있다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 전체 시간 바꾸기 | 01 TOTAL_MS 1500 → 750 | 호가 줄어드는 속도가 초당 201 에서 402 로 두 배가 되고 박동은 두 번에서 한 번만 든다. |
| 모래알 수 바꾸기 | 03 GRAIN_COUNT 600 → 1400 | 목이 2칸에서 4칸으로 넓어지고 더미가 27줄에서 36줄로 높아지며 마지막 알갱이는 33ms 늦게 지난다. |
| 출렁임 감쇠 바꾸기 | 07 SLOSH_DAMP 0.92 → 0.97 | 진폭이 7.0 에서 17.9 로 커지고 멈춘 뒤 잦아드는 시간이 600ms 에서 1650ms 가 된다. |
변형 표의 숫자도 위 세 절과 같은 노드 호출에서 나온 값이다.
컨트롤은 아홉 장면 모두 네이티브 <button> 둘이고 keydown·preventDefault·touch-action 은 한 줄도 없어서, Enter 와 Space 는 브라우저의 기본 버튼 동작에 맡긴다. 프로브로 Space 를 한 번 누르면 버튼 글자가 「멈춤」에서 「시작」으로 바뀌고 aria-pressed 가 true 에서 false 가 되며 상태 줄에 「멈췄습니다」가 적힌다. 숫자를 읽는 칸은 아홉 장면 모두 role="timer" 이고, 상태 줄 aria-live 는 장면마다 하나씩 있어 멈출 때와 끝날 때(「시간이 다 됐습니다」)에만 글을 쓰므로 매초 읽히지 않는다. role="img" 는 01·03·04·05·07·09 여섯 장면에 있고 02·06·08 은 role="timer" 칸의 aria-label 로 읽힌다. 포커스 표시는 :focus-visible 의 2px 윤곽선이며 320×200 에서 가로·세로 넘침은 아홉 장면 모두 0 이다. 움직임 줄이기 동작은 prefers-reduced-motion 문서의 미디어 쿼리를 그대로 쓴다.
css only countdown timer 만으로 충분한가?
남은 시간을 숫자로 보여 줄 필요가 없고 멈춤·다시 버튼도 없는 장식이면 CSS 만으로 충분하다. countdown timer html css javascript 로 직접 짜는 쪽은 숫자와 멈춤이 필요한 화면에서 비로소 값이 생긴다. 하지만 이 편의 아홉 장면은 transition 과 @keyframes 가 0 개이고 모든 값이 requestAnimationFrame 안에서 elapsed 로 계산되는데, 그래야 멈춤과 다시, 탭 복귀 때 건너뛰기, 시연 배속을 한 곳에서 다룰 수 있기 때문이다. 숫자가 올라가는 카운터는 CSS 숫자 증가 애니메이션 9종 — 한 줄 복붙으로가, 시계 바늘은 자바스크립트 아날로그 시계 9종 — 바늘 각도부터 톱니까지가 더 가깝고, 03 처럼 입자를 격자로 굴리는 쪽은 폴링 샌드(falling sand simulator) 9종 — 모래·물·불 셀 오토마타에서 이어진다. 캔버스를 쓰는 03·04·07·09 의 그리기 원리는 캔버스 애니메이션 문서에, 타임스탬프를 받는 쪽은 requestAnimationFrame 문서에 있다.
countdown timer css codepen 코드를 가져다 쓰기 전에 자주 묻는 질문
css only countdown timer 처럼 JS 없이 쓰고 싶으면 어디를 줄이나?
이 편의 장면은 JS 가 시간을 쥐므로 그대로는 줄일 수 없다. 05 의 부채꼴은 이미 --a 라는 CSS 변수 하나로 그려지지만, 그 값을 @keyframes 로 돌려 JS 없이 쓰는 방향은 이 편에서 해 보지 않았으니 직접 시험해 볼 몫이다.
전체 시간은 어디서 바꾸나?
01·03·06 은 TOTAL_MS, 04 는 BURN_MS, 07 은 DRAIN_MS 이고, 02·08·09 는 COUNT_FROM(초 개수), 05 는 TICK_COUNT 이다. 06 은 9.99초를 넘기지 말고 08 은 99 를 넘기지 않아야 한다.
화면이 작은 폰에서는 어떻게 보이나?
보기 칸 높이가 min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px) 이라 320×200 에서는 126px 로 줄고 넘침은 0 이다. 03 은 96×96 격자가 고정이라 칸만 작아지고 알갱이 수는 같다.








