01進捗値の分だけモジュールが一つずつ満ちるQRコード
生成の進捗値が0から1に満ちる間、格子モジュールが順に現れ、QRコードが次第に完成した姿になる。
使いどころ決済QRコード生成画面
QR_MODULE_REVEAL_STAGGER_MSbuildModulesqrRand

<main class="stage">
<div class="mrg is-demo" id="mrgRoot">
<div class="mrg__badge" id="mrgBadge">生成進行 0%</div>
<button class="mrg__ctrl" id="mrgCtrl" type="button">一時停止</button>
<div class="mrg__canvas-wrap">
<canvas class="mrg__canvas" id="mrgCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 시드 의사난수로 고정 격자(파인더 패턴 3개 + 장식 모듈)를 만들고,
// 행(row) 기준 스태거 + 모듈별 시드 지터로 지연 값을 계산해 위→아래로 번지듯 채운다.
// 실제 QR 인코딩이 아니라 장식용 고정 시드 격자다.
@include stage($stage-ink);
.mrg {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.mrg__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;
}
.mrg__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;
}
.mrg__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.mrg__canvas-wrap {
width: 100%;
aspect-ratio: 7 / 5;
border-radius: $r-card;
background: rgba(255, 255, 255, .04);
display: grid;
place-items: center;
overflow: hidden;
}
.mrg__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.mrg { gap: $sp-1; }
.mrg__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.mrg__canvas-wrap { max-width: 150px; aspect-ratio: 3 / 2; margin: 0 auto; }
}
@media (prefers-reduced-motion: reduce) {
.mrg__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);
}
.mrg {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.mrg__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;
}
.mrg__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;
}
.mrg__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.mrg__canvas-wrap {
width: 100%;
aspect-ratio: 7/5;
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
display: grid;
place-items: center;
overflow: hidden;
}
.mrg__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.mrg {
gap: 4px;
}
.mrg__badge {
padding: 4px 8px;
font-size: 10px;
}
.mrg__canvas-wrap {
max-width: 150px;
aspect-ratio: 3/2;
margin: 0 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.mrg__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// QR_MODULE_REVEAL_STAGGER_MS — 변형 1: 18 → 36 으로 늘리면 모듈이 한 칸씩 눈에 보이게 천천히 채워진다.
var QR_MODULE_REVEAL_STAGGER_MS = 18;
var SIZE = 17;
var LOOP_MS = 2000;
var REVEAL_MS = 220;
var canvas = document.getElementById('mrgCanvas');
var ctx = canvas.getContext('2d');
var badge = document.getElementById('mrgBadge');
var ctrl = document.getElementById('mrgCtrl');
var root = document.getElementById('mrgRoot');
function qrRand(seed) {
var s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function inFinder(x, y, size) {
return (x < 7 && y < 7) || (x < 7 && y >= size - 7) || (x >= size - 7 && y < 7);
}
function finderDark(x, y, size) {
var fx = x < 7 ? x : x - (size - 7);
var fy = y < 7 ? y : y - (size - 7);
if (fx === 0 || fx === 6 || fy === 0 || fy === 6) return true;
if (fx >= 2 && fx <= 4 && fy >= 2 && fy <= 4) return true;
return false;
}
function buildModules(size, seed) {
var rand = qrRand(seed);
var grid = [];
for (var y = 0; y < size; y += 1) {
var row = [];
for (var x = 0; x < size; x += 1) {
row.push(inFinder(x, y, size) ? finderDark(x, y, size) : rand() < 0.46);
}
grid.push(row);
}
return grid;
}
var modules = buildModules(SIZE, 407017);
var darkTotal = 0;
modules.forEach(function (row) { row.forEach(function (v) { if (v) darkTotal += 1; }); });
// 계산 한 겹: 행(row) 값을 스태거 기준으로 삼고, 시드 난수로 모듈마다 지연을 살짝 어긋내
// 파도가 위에서 아래로 자연스럽게 번지도록 순서·지연 값을 구한다 (실제 인코딩 아님).
var jitterRand = qrRand(20260924);
var order = [];
for (var y2 = 0; y2 < SIZE; y2 += 1) {
for (var x2 = 0; x2 < SIZE; x2 += 1) {
var jitter = (jitterRand() - 0.5) * QR_MODULE_REVEAL_STAGGER_MS;
order.push({ x: x2, y: y2, delay: y2 * QR_MODULE_REVEAL_STAGGER_MS + jitter });
}
}
var maxDelay = 0;
order.forEach(function (o) { if (o.delay > maxDelay) maxDelay = o.delay; });
var SWEEP_END = maxDelay + REVEAL_MS;
function draw(elapsed) {
var cw = canvas.width, ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
var cell = Math.min(cw, ch) / SIZE;
var ox = (cw - cell * SIZE) / 2;
var oy = (ch - cell * SIZE) / 2;
var revealedCount = 0;
for (var i = 0; i < order.length; i += 1) {
var o = order[i];
if (!modules[o.y][o.x]) continue;
var local = elapsed - o.delay;
var p = local <= 0 ? 0 : (local >= REVEAL_MS ? 1 : local / REVEAL_MS);
if (p <= 0) continue;
revealedCount += 1;
var scale = 0.4 + 0.6 * p;
var size = cell * 0.86 * scale;
var cx = ox + o.x * cell + cell / 2;
var cy = oy + o.y * cell + cell / 2;
ctx.globalAlpha = p;
ctx.fillStyle = 'rgb(255,247,230)';
ctx.fillRect(cx - size / 2, cy - size / 2, size, size);
}
ctx.globalAlpha = 1;
var pct = Math.round((revealedCount / darkTotal) * 100);
badge.textContent = '生成進行 ' + pct + '%';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? SWEEP_END : 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);
}
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 "./ModuleRevealGrid.module.scss";
export default function ModuleRevealGrid() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("생성 진행 0%");
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;
// QR_MODULE_REVEAL_STAGGER_MS — 변형 1: 18 → 36 으로 늘리면 모듈이 한 칸씩 눈에 보이게 천천히 채워진다.
const QR_MODULE_REVEAL_STAGGER_MS = 18;
const SIZE = 17;
const LOOP_MS = 2000;
const REVEAL_MS = 220;
function qrRand(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function inFinder(x: number, y: number, size: number) {
return (x < 7 && y < 7) || (x < 7 && y >= size - 7) || (x >= size - 7 && y < 7);
}
function finderDark(x: number, y: number, size: number) {
const fx = x < 7 ? x : x - (size - 7);
const fy = y < 7 ? y : y - (size - 7);
if (fx === 0 || fx === 6 || fy === 0 || fy === 6) return true;
if (fx >= 2 && fx <= 4 && fy >= 2 && fy <= 4) return true;
return false;
}
function buildModules(size: number, seed: number) {
const rand = qrRand(seed);
const grid: boolean[][] = [];
for (let y = 0; y < size; y += 1) {
const row: boolean[] = [];
for (let x = 0; x < size; x += 1) {
row.push(inFinder(x, y, size) ? finderDark(x, y, size) : rand() < 0.46);
}
grid.push(row);
}
return grid;
}
const modules = buildModules(SIZE, 407017);
let darkTotal = 0;
modules.forEach((row) => row.forEach((v) => { if (v) darkTotal += 1; }));
// 계산 한 겹: 행(row) 값을 스태거 기준으로 삼고, 시드 난수로 모듈마다 지연을 살짝 어긋내
// 파도가 위에서 아래로 자연스럽게 번지도록 순서·지연 값을 구한다 (실제 인코딩 아님).
const jitterRand = qrRand(20260924);
const order: { x: number; y: number; delay: number }[] = [];
for (let y2 = 0; y2 < SIZE; y2 += 1) {
for (let x2 = 0; x2 < SIZE; x2 += 1) {
const jitter = (jitterRand() - 0.5) * QR_MODULE_REVEAL_STAGGER_MS;
order.push({ x: x2, y: y2, delay: y2 * QR_MODULE_REVEAL_STAGGER_MS + jitter });
}
}
let maxDelay = 0;
order.forEach((o) => { if (o.delay > maxDelay) maxDelay = o.delay; });
const SWEEP_END = maxDelay + REVEAL_MS;
function draw(elapsed: number) {
const cw = canvas!.width, ch = canvas!.height;
ctx!.clearRect(0, 0, cw, ch);
const cell = Math.min(cw, ch) / SIZE;
const ox = (cw - cell * SIZE) / 2;
const oy = (ch - cell * SIZE) / 2;
let revealedCount = 0;
for (let i = 0; i < order.length; i += 1) {
const o = order[i];
if (!modules[o.y][o.x]) continue;
const local = elapsed - o.delay;
const p = local <= 0 ? 0 : local >= REVEAL_MS ? 1 : local / REVEAL_MS;
if (p <= 0) continue;
revealedCount += 1;
const scale = 0.4 + 0.6 * p;
const size = cell * 0.86 * scale;
const cx = ox + o.x * cell + cell / 2;
const cy = oy + o.y * cell + cell / 2;
ctx!.globalAlpha = p;
ctx!.fillStyle = "rgb(255,247,230)";
ctx!.fillRect(cx - size / 2, cy - size / 2, size, size);
}
ctx!.globalAlpha = 1;
const pct = Math.round((revealedCount / darkTotal) * 100);
setBadge("생성 진행 " + pct + "%");
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? SWEEP_END : 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);
}
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);
ctrl.removeEventListener("click", onClick);
ctrl.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mrg} is-demo`} ref={rootRef}>
<div className={styles.mrg__badge}>{badge}</div>
<button className={styles.mrg__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["mrg__canvas-wrap"]}>
<canvas className={styles.mrg__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}01 進捗値の分だけモジュールが一つずつ満ちるQRコード — 計測 累積面積 17.923% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · qr コード アニメーション css 9選 — 残り時間
このデモが属する記事
qr コード アニメーション css 9選 — 残り時間スキャン失敗のせいでもなく、ただ長く浮いているせいでもない — qrコードローディングアニメーションの残り時間の比率の値ひとつが尽きることでリングが狭まり、角度が削られ、終わりに触れると揺れる。3番はその値でパルスリングの半径を狭め、4番は この記事のデモをすべて見る

