css countdown timer 9パターン — 残り時間がリング・砂・導火線で減るタイマー
css countdown timer の残り時間は、数字を読む前に形が縮む速さで先に感じ取られる — 01番のリングと03番の砂がその速さを計算で決める。九つの場面はどれも transition も @keyframes も使わず、JavaScript で計算した値だけで動きます。
九つのカウントダウンタイマーを画面でそのまま動かしてみる
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 残りの割合で色が移り、最後の区間で脈打つ減っていくリング
- 02 折れる角度に合わせて影が回るフリップ数字カード
- 03 砂粒が細い首を通って積もり時間を測る砂時計
- 04 火のついた点が導火線の長さに沿って進み火の粉が飛ぶカウントダウン
- 05 目盛りごとにひと跳ねして通った扇形が塗られる文字盤
- 06 帯が速く回るほどにじみ、繰り上がりが連鎖する走行距離計の数字
- 07 水位が下がるほど揺れ、止まると静まる液体タンクのタイマー
- 08 消えたセグメントに残光が残る7セグLEDカウントダウン
- 09 3・2・1ごとに衝撃波が広がり0で点火する打ち上げカウントダウンの場面
css countdown timer 9パターン — 減衰・ばね・経路の長さで時間が見える
一つの場面は、時計の表示欄とボタンの行を8px間隔で縦に重ねたステージ(描画の撮影では480×300)です。ステージ内側の余白は clamp(8px, 4vw, 24px) なので幅480pxでは19.2pxになり、表示欄の高さは最大196px、320×200の画面では126pxまで縮みます。ボタンは「開始」(動作中は「一時停止」)と「リセット」の二つで、CSS で決めているのは高さ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の五つは倍速なしで、小数第1位までの秒を実際の長さのまま表示します。実演で見た拍と、自分で押して回したときの拍は、この四つの場面では違うということです。
並べる順は、縮んでいく面がどこから生まれるかで決めました。01は弧の長さが残り割合そのもの、02と05はばねの方程式を自前で積分し、03・04・09は種を固定した乱数で粒や星を動かします。06は桁の帯、08はセグメントの明るさ、07は水位の変化量から波の振幅を引き出す造りです。料理や試験の残り時間なら01から、イベントの開始なら02か09から、集中タイマーなら03から見るのが近道でしょう。数字が増えていく方向は CSS 数字 カウントアップ アニメーション9選 — 一行でコピペOK、値をゲージで見せる方向は CSS プログレスバー 円形ゲージ9選 — 数値を変えるだけ、期限つきの読み込み表示は qr コード アニメーション css 9選 — 残り時間 を横に開いておくと比べやすくなります。
表の値は、直近の描画で測った結果をそのまま移したものです。一つの場面につき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 7セグメント | 46.8% | 43.9 | 23/23 |
| 09 打ち上げ場面 | 47.1% | 82.1 | 23/23 |
countdown timer design html css を技法別のコードで見る
コードの断片は、場面ごとの index.html の <script> の中から、つながった行だけをそのまま持ってきたもので、どれも25行以内です。countdown timer css codepen で見つけた断片と比べるときは、残り時間がどの変数から出てくるかを最初に見てください。ここでは remainMs() が elapsed から計算しています。定数の名前は各項目の文に書いたので、値を変える場所は const の行で探せます。
01残りの割合で色が移り、最後の区間で脈打つ減っていくリング
弧の長さは周の長さ301.6に残り割合を掛けた strokeDashoffset で縮め、同じ割合を三色(ミント・淡い黄・ピンク)の線形補間 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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を掛けて影の濃さにします。減衰は DAMPING = 2*0.55*sqrt(STIFFNESS) の式で決まり、その計算値ではばねは12.6%行き過ぎます。表示する角度は別に clamp(c.x, 0, 1.12)*180 で切られるので、最大は201.6°です。下の 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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%)行き過ぎ、減衰の周期は314.6msです。会議の時間表示や運動のインターバルに合い、面積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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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消えたセグメントに残光が残る7セグLEDカウントダウン
セグメントごとに明るさ b を持たせ、点灯の瞬間に OVERDRIVE の1.45まで跳ね上げて approach で1まで落ち着かせます。消えたあとはフレームごとに DECAY の0.84倍へ減らし、蛍光体が冷めていく尾を残します。60fpsで数えると、DECAY から計算すると、消えてから明るさが0.05を下回るまで17.2フレーム、約286msかかります(0.05は計算上の基準で、ソースの実際の打ち切りは b < 0.004 です)。レトロな電光表示や家電の表示窓に合い、面積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="残り時間の7セグメント表示"></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 · 消えたセグメントに残光が残る7セグLEDカウントダウン — 計測 累積面積 46.838% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 明るさ 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 倍、つまり120に上げ、速さは1フレームあたり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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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を下回ると、1フレーム最大12ステップの制限と50msの制限のせいで砂が時計より後ろに下がります。粒の数が上の部屋の収容量(首2マスで2302、4マスで2348)を超えると、許容量が実際の粒の数より大きくなります。格子は96×96で固定なので、窓の大きさが変わっても解像度は同じままです。
二桁・三桁を超える時間は桁が足りなくなる
08は COUNT_FROM が100以上だと MASKS[10] が undefined になり、mask.indexOf でエラーが出ます。二つの桁で表示できるのは99までです。06は三桁の表示なので、残り時間が9.99秒を超えると足りなくなるのは一桁分で、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 だけで足ります。JavaScript で組む側は、数字と一時停止が要る画面ではじめて意味が出ます。このカウントダウン九つは transition も @keyframes も0個で、値はすべて requestAnimationFrame の中で elapsed から計算しています。そうしておけば、一時停止とリセット、タブが戻ったときの飛び、実演の倍速を一か所で扱えるからです。カウントダウン タイマー css の考え方を数字が増える側へ広げるなら CSS 数字 カウントアップ アニメーション9選 — 一行でコピペOK、針で見せる時計なら css 時計 アナログ 9選 — 針の角度から歯車までが近く、03のように粒を格子で動かす造りは フォーリングサンド(falling sand simulator) 9種の作り方 へ続きます。キャンバスを使う03・04・07・09の描画の仕組みは キャンバスのアニメーションの文書に、タイムスタンプを受け取る側は requestAnimationFrame の文書にあります。
countdown timer css codepen のコードを使い回す前によくある質問
countdown timer design html css を JavaScript なしで使いたいときはどこを削りますか
この編の場面は JavaScript が時間を握っているので、そのままでは削れません。05の扇形の角度はすでに --a という CSS 変数で conic-gradient に渡されていますが、その値を毎フレーム計算しているのは JavaScript です。JavaScript なしで切り替える方法は、この記事では試していません。
全体の時間はどこで変えますか
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の格子が固定のため、マスが小さくなるだけで粒の数は同じです。








