08消えたセグメントに残光が残る7セグLEDカウントダウン
セグメントごとに明るさの値を持ち、点灯の瞬間は過明るく跳ね、消灯では毎フレーム指数で減衰して蛍光体が冷める尾を残す。
使いどころレトロな電光表示の時計・家電の表示窓
SEG_ONDECAYOVERDRIVEGLOW_PX

<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 に同梱 · css countdown timer 9パターン — 残り時間がリング・砂・導火線で減るタイマー
このデモが属する記事
css countdown timer 9パターン — 残り時間がリング・砂・導火線で減るタイマーcss countdown timer の残り時間は、数字を読む前に形が縮む速さで先に感じ取られる — 01番のリングと03番の砂がその速さを計算で決める。九つの場面はどれも transition も @keyframes も使わず、Jav この記事のデモをすべて見る


