03QR Code Whose Pulse Ring Narrows by the Time Left Value
The radius value of the ring wrapped around the code shrinks with the time left ratio value, so the eye can see expiry approaching.
Best forCoupon QR code near-expiry indicator
PULSE_RING_INTERVAL_MSringsForMAX_RADIUS

<main class="stage">
<div class="pre is-demo" id="preRoot">
<div class="pre__badge" id="preBadge">Valid remaining 100%</div>
<button class="pre__ctrl" id="preCtrl" type="button">Pause</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 = 'Valid remaining ' + 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 ? 'Pause' : 'Play';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
elapsed = 0;
playing = true;
ctrl.textContent = 'Pause';
}
});
})();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 Code Whose Pulse Ring Narrows by the Time Left Value — Measured area 40.212% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · QR Code Loading Animation — 9 CSS Expiry Patterns
The post this demo lives in
QR Code Loading Animation — 9 CSS Expiry PatternsNot a scan failure, not sitting too long — one time left ratio for qr code loading animation must run out: item 3 shrink See all demos in this post
If you like this effect - similar demos
Loaders02 Orb Pulse Whose Breathing Widens With a Growing Wait-time ValueAI Loading Animation: 9 Patterns by One Value
Loaders05 Icon progress ring — an indicator whose circular progress value fills with the pulled distance and morphs into a checkmark once completePull to Refresh CSS: 9 Loading Patterns
Loaders04 Loader Whose Stacked Rings Narrow Their Tilt Gap as the Value RisesCSS 3D Cube Animation Loaders, 9 Patterns