04A Countdown Where the Burning Point Walks Along the Fuse Length and Throws Sparks
The wavy path is walked by arc length to find the burning point, and ember particles scatter from it along the normal. The burnt stretch is drawn dim like ash, and only the remaining rope stays bright.
Best forPre-round tension in games and quiz-show time limits
PATH_LENEMBER_NBURN_MSSPARK_SPREAD

<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="A flame burning along a fuse"></canvas>
<p class="cd__read cd__read--corner" role="timer"><b data-r="num">1.5</b><span>s</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">Pause</button>
<button class="cd__btn" type="button" data-r="reset">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 에 맞춘다. 리사이즈마다 Reset 호출된다
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 ? "Pause" : "Start";
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("Time is up");
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 ? "" : "Paused");
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 A Countdown Where the Burning Point Walks Along the Fuse Length and Throws Sparks — Measured area 57.0% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Countdown Timer: 9 Clocks That Drain and Flip
The post this demo lives in
CSS Countdown Timer: 9 Clocks That Drain and FlipA css countdown timer is felt as the speed at which a shape shrinks before the number is read — scene 01's ring and scen See all demos in this post
If you like this effect - similar demos
Dashboard UI05 An Axis That Re-Picks 1-2-5 Ticks and Shrinks Along With the BarsBar Chart Animation: 9 Demos From Spring to Race
Dashboard UI01 Crosshair that snaps to the nearest pointChart UI Design: 9 Interactions You Can Drag
Dashboard UI01 A Card That Follows the Cursor and Tilts With Its MotionCSS Kanban Board: 9 Demos With Springs and Lerp