03残り時間値の分だけ狭まるパルスリングのQRコード
コードを囲むパルスリングの半径値が残り時間の比率値に沿って次第に狭まり、期限切れが近いことを目に見せる。
使いどころクーポンQRコードの期限切れ間近表示
PULSE_RING_INTERVAL_MSringsForMAX_RADIUS

<main class="stage">
<div class="pre is-demo" id="preRoot">
<div class="pre__badge" id="preBadge">残り有効 100%</div>
<button class="pre__ctrl" id="preCtrl" type="button">一時停止</button>
<div class="pre__canvas-wrap">
<canvas class="pre__canvas" id="preCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 루프 안에서 남은 유효시간 비율을 계산해 그 값을 링의 최대 반경 스케일에
// 그대로 곱한다 — 만료가 가까워질수록 링이 더 작게, 더 빨리 사그라든다.
@include stage($stage-ink);
.pre {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.pre__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .08);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pre__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(255, 247, 230, .12);
color: $subject-cream;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.pre__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.pre__canvas-wrap {
width: 100%;
height: 220px;
border-radius: $r-card;
background: rgba(255, 255, 255, .04);
display: grid;
place-items: center;
overflow: hidden;
}
.pre__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.pre { gap: $sp-1; }
.pre__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.pre__canvas-wrap { height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.pre__canvas-wrap { background: rgba(255, 255, 255, .04); }
}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);
}
.pre {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.pre__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pre__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.pre__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.pre__canvas-wrap {
width: 100%;
height: 220px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
display: grid;
place-items: center;
overflow: hidden;
}
.pre__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.pre {
gap: 4px;
}
.pre__badge {
padding: 4px 8px;
font-size: 10px;
}
.pre__canvas-wrap {
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.pre__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// PULSE_RING_INTERVAL_MS — 변형 1: 320 → 700 으로 늘리면 링이 더 드문드문 퍼진다.
// 320 으로 좁혀 2초 측정 창 안에 동시에 겹치는 링 수를 늘린다(화면을 채우는 시연 스케줄).
var PULSE_RING_INTERVAL_MS = 320;
var LOOP_MS = 2000;
var RING_LIFE_MS = 1100;
var MAX_RADIUS = 88; // fit() 이 캔버스 크기에 맞춰 화면 폭까지 퍼지도록 재계산한다
var canvas = document.getElementById('preCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var badge = document.getElementById('preBadge');
var ctrl = document.getElementById('preCtrl');
var root = document.getElementById('preRoot');
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(407033);
var qrDots = [];
for (var y = 0; y < 9; y += 1) {
for (var x = 0; x < 9; x += 1) {
if (rand() < 0.42) qrDots.push({ x: x, y: y });
}
}
function ringsFor(elapsed) {
var out = [];
var firstIndex = Math.ceil((elapsed - RING_LIFE_MS) / PULSE_RING_INTERVAL_MS);
var lastIndex = Math.floor(elapsed / PULSE_RING_INTERVAL_MS);
for (var i = Math.max(0, firstIndex); i <= lastIndex; i += 1) {
var born = i * PULSE_RING_INTERVAL_MS;
var age = elapsed - born;
if (age < 0 || age > RING_LIFE_MS) continue;
out.push(age / RING_LIFE_MS);
}
return out;
}
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 중심·최대 반경을 다시 잰다 —
// 링이 항상 스테이지 폭까지 퍼지게(278px 캡 없이 반응형).
var dpr = 1, cw = 240, ch = 200, cx = 120, cy = 100;
function fit() {
var r = wrap.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
cw = r.width; ch = r.height;
canvas.width = Math.round(cw * dpr);
canvas.height = Math.round(ch * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cx = cw / 2; cy = ch / 2;
MAX_RADIUS = (cw / 2) * 1.08;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
ctx.clearRect(0, 0, cw, ch);
// 계산 한 겹: 루프 안 남은 유효시간 비율이 곧 그대로 링의 최대 반경 스케일이 된다
// (남은 시간이 줄수록 링이 더 작게 퍼진다 = 만료 임박 표시).
var remain = 1 - (elapsed % LOOP_MS) / LOOP_MS;
var radiusScale = 0.4 + 0.6 * remain;
var strokeBase = Math.max(2.5, cw * 0.02);
ctx.globalCompositeOperation = 'lighter';
ringsFor(elapsed).forEach(function (p) {
var r = p * MAX_RADIUS * radiusScale;
var alpha = (1 - p) * 0.5;
// 글로우 패스 — 굵고 옅은 띠를 먼저 얹어 화면에 퍼지는 면적을 넓힌다.
ctx.strokeStyle = 'rgba(120,200,255,' + (alpha * 0.55).toFixed(3) + ')';
ctx.lineWidth = strokeBase * 3.2;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// 선명 패스
ctx.strokeStyle = 'rgba(150,215,255,' + alpha.toFixed(3) + ')';
ctx.lineWidth = strokeBase;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over';
var box = Math.min(cw, ch) * 0.32;
ctx.fillStyle = 'rgba(20,26,38,.9)';
ctx.fillRect(cx - box / 2, cy - box / 2, box, box);
var cell = box / 9;
ctx.fillStyle = 'rgb(255,247,230)';
qrDots.forEach(function (d) {
var s = cell * 0.62;
ctx.fillRect(cx - box / 2 + d.x * cell + (cell - s) / 2, cy - box / 2 + d.y * cell + (cell - s) / 2, s, s);
});
badge.textContent = '残り有効 ' + Math.round(remain * 100) + '%';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? LOOP_MS - 1 : 0;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '一時停止' : '再生';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
elapsed = 0;
playing = true;
ctrl.textContent = '一時停止';
}
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./PulseRingExpire.module.scss";
export default function PulseRingExpire() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("남은 유효 100%");
const [ctrlLabel, setCtrlLabel] = useState("일시정지");
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctrl = ctrlRef.current;
if (!root || !canvas || !ctrl) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// PULSE_RING_INTERVAL_MS — 변형 1: 320 → 700 으로 늘리면 링이 더 드문드문 퍼진다.
// 320 으로 좁혀 2초 측정 창 안에 동시에 겹치는 링 수를 늘린다(화면을 채우는 시연 스케줄).
const PULSE_RING_INTERVAL_MS = 320;
const LOOP_MS = 2000;
const RING_LIFE_MS = 1100;
let MAX_RADIUS = 88; // fit() 이 캔버스 크기에 맞춰 화면 폭까지 퍼지도록 재계산한다
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const rand = seedRand(407033);
const qrDots: { x: number; y: number }[] = [];
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
if (rand() < 0.42) qrDots.push({ x, y });
}
}
function ringsFor(elapsed: number) {
const out: number[] = [];
const firstIndex = Math.ceil((elapsed - RING_LIFE_MS) / PULSE_RING_INTERVAL_MS);
const lastIndex = Math.floor(elapsed / PULSE_RING_INTERVAL_MS);
for (let i = Math.max(0, firstIndex); i <= lastIndex; i += 1) {
const born = i * PULSE_RING_INTERVAL_MS;
const age = elapsed - born;
if (age < 0 || age > RING_LIFE_MS) continue;
out.push(age / RING_LIFE_MS);
}
return out;
}
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 중심·최대 반경을 다시 잰다 —
// 링이 항상 스테이지 폭까지 퍼지게(278px 캡 없이 반응형).
let dpr = 1, cw = 240, ch = 200, cx = 120, cy = 100;
function fit() {
const r = wrap.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
cw = r.width; ch = r.height;
canvas!.width = Math.round(cw * dpr);
canvas!.height = Math.round(ch * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
cx = cw / 2; cy = ch / 2;
MAX_RADIUS = (cw / 2) * 1.08;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
ctx!.clearRect(0, 0, cw, ch);
// 계산 한 겹: 루프 안 남은 유효시간 비율이 곧 그대로 링의 최대 반경 스케일이 된다
// (남은 시간이 줄수록 링이 더 작게 퍼진다 = 만료 임박 표시).
const remain = 1 - (elapsed % LOOP_MS) / LOOP_MS;
const radiusScale = 0.4 + 0.6 * remain;
const strokeBase = Math.max(2.5, cw * 0.02);
ctx!.globalCompositeOperation = "lighter";
ringsFor(elapsed).forEach((p) => {
const r = p * MAX_RADIUS * radiusScale;
const alpha = (1 - p) * 0.5;
// 글로우 패스 — 굵고 옅은 띠를 먼저 얹어 화면에 퍼지는 면적을 넓힌다.
ctx!.strokeStyle = "rgba(120,200,255," + (alpha * 0.55).toFixed(3) + ")";
ctx!.lineWidth = strokeBase * 3.2;
ctx!.beginPath();
ctx!.arc(cx, cy, r, 0, Math.PI * 2);
ctx!.stroke();
// 선명 패스
ctx!.strokeStyle = "rgba(150,215,255," + alpha.toFixed(3) + ")";
ctx!.lineWidth = strokeBase;
ctx!.beginPath();
ctx!.arc(cx, cy, r, 0, Math.PI * 2);
ctx!.stroke();
});
ctx!.globalCompositeOperation = "source-over";
const box = Math.min(cw, ch) * 0.32;
ctx!.fillStyle = "rgba(20,26,38,.9)";
ctx!.fillRect(cx - box / 2, cy - box / 2, box, box);
const cell = box / 9;
ctx!.fillStyle = "rgb(255,247,230)";
qrDots.forEach((d) => {
const s = cell * 0.62;
ctx!.fillRect(cx - box / 2 + d.x * cell + (cell - s) / 2, cy - box / 2 + d.y * cell + (cell - s) / 2, s, s);
});
setBadge("남은 유효 " + Math.round(remain * 100) + "%");
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? LOOP_MS - 1 : 0;
if (reduce) {
playing = false;
root.classList.remove("is-demo");
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
function onClick() {
playing = !playing;
setCtrlLabel(playing ? "일시정지" : "재생");
}
function onKeydown(e: KeyboardEvent) {
if ((e.key === "r" || e.key === "R") && !reduce) {
elapsed = 0;
playing = true;
setCtrlLabel("일시정지");
}
}
ctrl.addEventListener("click", onClick);
ctrl.addEventListener("keydown", onKeydown);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
ctrl.removeEventListener("click", onClick);
ctrl.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pre} is-demo`} ref={rootRef}>
<div className={styles.pre__badge}>{badge}</div>
<button className={styles.pre__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["pre__canvas-wrap"]}>
<canvas className={styles.pre__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}03 残り時間値の分だけ狭まるパルスリングのQRコード — 計測 累積面積 40.212% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · qr コード アニメーション css 9選 — 残り時間
このデモが属する記事
qr コード アニメーション css 9選 — 残り時間スキャン失敗のせいでもなく、ただ長く浮いているせいでもない — qrコードローディングアニメーションの残り時間の比率の値ひとつが尽きることでリングが狭まり、角度が削られ、終わりに触れると揺れる。3番はその値でパルスリングの半径を狭め、4番は この記事のデモをすべて見る

