qr 코드 만료 카운트다운 css 애니메이션 9가지
qr 코드 만료 표시는 값 하나로 아홉 장면을 만든다. 스캔 실패 때문도 아니고 그냥 오래 떠 있어서도 아니다 — qr 코드 만료까지 남은시간 비율 값 하나가 사라져야 링이 좁아지고, 각도가 깎이고, 끝에 닿으면 흔들린다.
01 qr-code-loading-module-reveal-grid
02 qr-code-loading-scan-corner-frame
03 qr-code-loading-pulse-ring-expire
04 qr-code-loading-expiry-countdown-ring
05 qr-code-loading-scan-success-checkmark
06 qr-code-loading-generate-noise-settle
07 qr-code-loading-amount-roll-swap
08 qr-code-loading-expired-shake-retry
09 qr-code-loading-multi-code-carousel
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 진행 값만큼 모듈이 하나씩 차오르는 QR 코드
- 02 코너 프레임이 깜빡이며 인식을 기다리는 QR 코드
- 03 남은시간 값만큼 좁아지는 펄스 링 QR 코드
- 04 남은시간 값이 각도 값으로 깎이는 카운트다운 링
- 05 인식되는 순간 체크 모양으로 접히는 QR 코드
- 06 흔들림 폭이 줄며 자리 잡는 생성 중 QR 코드
- 07 결제 금액 자릿수가 굴러 바뀌는 QR 코드 패널
- 08 남은시간 값이 0에 닿으면 흔들리는 만료 QR 코드
- 09 여러 코드가 인덱스 값을 따라 넘어가는 QR 캐러셀
아래 QR 격자는 모두 고정 시드 의사난수로 찍은 장식 패턴이다. 실제 데이터를 인코딩하지 않으므로 카메라로 스캔해도 읽히지 않는다 — 칸이 드러나는 순서·링이 좁아지는 반경·자릿수가 구르는 시차처럼 "값 하나가 움직이는 방식"만 보여주는 목업이다. 아홉 개는 그 값을 어디에 대입하느냐로 나뉜다 — 01·06·09는 지연·진폭·인덱스 값으로 모듈이나 코드를 순서대로 바꾸고, 02·03·04·08은 거리·비율·각도 값으로 링이나 코너의 밝기·크기를 바꾸며, 05·07은 판정·전환 시점 값으로 스프링이나 굴림 애니메이션을 튕긴다.
01지연 값 하나로 위에서 아래로 번지는 모듈 리빌 QR
생성 진행 값이 0에서 1로 오르는 동안, 행(row) 값을 기준으로 삼은 스태거 지연에 시드 난수 지터를 더한 값 하나가 각 모듈이 드러나는 순서를 정한다. 결제 QR 코드 생성 화면에서 격자가 위에서 아래로 조립되듯 채워지는 느낌을 만든다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
측정: 누적면적 17.9%, 강도 평균 83.8, 9/23 프레임에서 움직였다.
02스캔선과 모서리 사이 거리 값이 코너를 밝히는 QR
스캔선이 위아래로 오가며 매 프레임 각 모서리까지 남은 거리 값을 재고, 그 값이 0에 가까울수록 코너 표시의 두께와 밝기가 커진다. 매장 결제 스캔 대기 화면에서 카메라가 곧 인식할 것 같은 신호를 준다.

<main class="stage">
<div class="scf is-demo" id="scfRoot">
<div class="scf__badge" id="scfBadge">스캔 중 0%</div>
<button class="scf__ctrl" id="scfCtrl" type="button">일시정지</button>
<div class="scf__canvas-wrap">
<canvas class="scf__canvas" id="scfCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 스캔선의 y 좌표와 각 코너 사이 거리로 발광 강도(0~1)를 계산해
// 스캔선이 지나가는 순간에만 모서리 프레임이 밝아지도록 한다.
@include stage($stage-ink);
.scf {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.scf__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;
}
.scf__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;
}
.scf__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.scf__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;
}
.scf__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.scf { gap: $sp-1; }
.scf__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.scf__canvas-wrap { max-width: 150px; aspect-ratio: 3 / 2; margin: 0 auto; }
}
@media (prefers-reduced-motion: reduce) {
.scf__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);
}
.scf {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.scf__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;
}
.scf__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;
}
.scf__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.scf__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;
}
.scf__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.scf {
gap: 4px;
}
.scf__badge {
padding: 4px 8px;
font-size: 10px;
}
.scf__canvas-wrap {
max-width: 150px;
aspect-ratio: 3/2;
margin: 0 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.scf__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// SCF_CORNER_GLOW_RANGE_PX — 변형 1: 26 → 46 으로 늘리면 코너가 더 일찍, 더 오래 밝아진다.
var SCF_CORNER_GLOW_RANGE_PX = 26;
var SWEEP_MS = 1400;
var SIZE = 15;
var canvas = document.getElementById('scfCanvas');
var ctx = canvas.getContext('2d');
var badge = document.getElementById('scfBadge');
var ctrl = document.getElementById('scfCtrl');
var root = document.getElementById('scfRoot');
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
var rand = seedRand(407025);
var dots = [];
for (var y = 0; y < SIZE; y += 1) {
for (var x = 0; x < SIZE; x += 1) {
if (rand() < 0.4) dots.push({ x: x, y: y });
}
}
function cornerGlow(dist) {
// 계산 한 겹: 스캔선까지 거리 → 코너 발광 강도(0~1), 범위 밖이면 0
var p = 1 - dist / SCF_CORNER_GLOW_RANGE_PX;
return p < 0 ? 0 : (p > 1 ? 1 : p);
}
function draw(elapsed) {
var cw = canvas.width, ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
var pad = 28;
var boxX = pad, boxY = pad, boxW = cw - pad * 2, boxH = ch - pad * 2;
var cell = boxW / SIZE;
ctx.fillStyle = 'rgba(255,247,230,.65)';
dots.forEach(function (d) {
var s = cell * 0.55;
ctx.fillRect(boxX + d.x * cell + (cell - s) / 2, boxY + d.y * cell + (cell - s) / 2, s, s);
});
var t = (elapsed % SWEEP_MS) / SWEEP_MS;
var scanY = boxY + t * boxH;
var grad = ctx.createLinearGradient(0, scanY - 18, 0, scanY + 18);
grad.addColorStop(0, 'rgba(120,200,255,0)');
grad.addColorStop(0.5, 'rgba(120,200,255,.55)');
grad.addColorStop(1, 'rgba(120,200,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(boxX, scanY - 18, boxW, 36);
ctx.strokeStyle = 'rgba(180,225,255,.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(boxX, scanY);
ctx.lineTo(boxX + boxW, scanY);
ctx.stroke();
var corners = [
{ x: boxX, y: boxY, dx: 1, dy: 1 },
{ x: boxX + boxW, y: boxY, dx: -1, dy: 1 },
{ x: boxX, y: boxY + boxH, dx: 1, dy: -1 },
{ x: boxX + boxW, y: boxY + boxH, dx: -1, dy: -1 }
];
var len = 18;
corners.forEach(function (c) {
var g = cornerGlow(Math.abs(scanY - c.y));
var lw = 3 + g * 2.5;
ctx.strokeStyle = 'rgba(' + (180 + g * 75) + ',' + (225) + ',255,' + (0.55 + g * 0.45) + ')';
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(c.x, c.y + c.dy * len);
ctx.lineTo(c.x, c.y);
ctx.lineTo(c.x + c.dx * len, c.y);
ctx.stroke();
});
badge.textContent = '스캔 중 ' + Math.round(t * 100) + '%';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? SWEEP_MS : 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 >= SWEEP_MS) elapsed -= SWEEP_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 "./ScanCornerFrame.module.scss";
export default function ScanCornerFrame() {
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;
// SCF_CORNER_GLOW_RANGE_PX — 변형 1: 26 → 46 으로 늘리면 코너가 더 일찍, 더 오래 밝아진다.
const SCF_CORNER_GLOW_RANGE_PX = 26;
const SWEEP_MS = 1400;
const SIZE = 15;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const rand = seedRand(407025);
const dots: { x: number; y: number }[] = [];
for (let y = 0; y < SIZE; y += 1) {
for (let x = 0; x < SIZE; x += 1) {
if (rand() < 0.4) dots.push({ x, y });
}
}
function cornerGlow(dist: number) {
// 계산 한 겹: 스캔선까지 거리 → 코너 발광 강도(0~1), 범위 밖이면 0
const p = 1 - dist / SCF_CORNER_GLOW_RANGE_PX;
return p < 0 ? 0 : p > 1 ? 1 : p;
}
function draw(elapsed: number) {
const cw = canvas!.width, ch = canvas!.height;
ctx!.clearRect(0, 0, cw, ch);
const pad = 28;
const boxX = pad, boxY = pad, boxW = cw - pad * 2, boxH = ch - pad * 2;
const cell = boxW / SIZE;
ctx!.fillStyle = "rgba(255,247,230,.65)";
dots.forEach((d) => {
const s = cell * 0.55;
ctx!.fillRect(boxX + d.x * cell + (cell - s) / 2, boxY + d.y * cell + (cell - s) / 2, s, s);
});
const t = (elapsed % SWEEP_MS) / SWEEP_MS;
const scanY = boxY + t * boxH;
const grad = ctx!.createLinearGradient(0, scanY - 18, 0, scanY + 18);
grad.addColorStop(0, "rgba(120,200,255,0)");
grad.addColorStop(0.5, "rgba(120,200,255,.55)");
grad.addColorStop(1, "rgba(120,200,255,0)");
ctx!.fillStyle = grad;
ctx!.fillRect(boxX, scanY - 18, boxW, 36);
ctx!.strokeStyle = "rgba(180,225,255,.9)";
ctx!.lineWidth = 2;
ctx!.beginPath();
ctx!.moveTo(boxX, scanY);
ctx!.lineTo(boxX + boxW, scanY);
ctx!.stroke();
const corners = [
{ x: boxX, y: boxY, dx: 1, dy: 1 },
{ x: boxX + boxW, y: boxY, dx: -1, dy: 1 },
{ x: boxX, y: boxY + boxH, dx: 1, dy: -1 },
{ x: boxX + boxW, y: boxY + boxH, dx: -1, dy: -1 },
];
const len = 18;
corners.forEach((c) => {
const g = cornerGlow(Math.abs(scanY - c.y));
const lw = 3 + g * 2.5;
ctx!.strokeStyle = "rgba(" + (180 + g * 75) + "," + 225 + ",255," + (0.55 + g * 0.45) + ")";
ctx!.lineWidth = lw;
ctx!.beginPath();
ctx!.moveTo(c.x, c.y + c.dy * len);
ctx!.lineTo(c.x, c.y);
ctx!.lineTo(c.x + c.dx * len, c.y);
ctx!.stroke();
});
setBadge("스캔 중 " + Math.round(t * 100) + "%");
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? SWEEP_MS : 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 >= SWEEP_MS) elapsed -= SWEEP_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.scf} is-demo`} ref={rootRef}>
<div className={styles.scf__badge}>{badge}</div>
<button className={styles.scf__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["scf__canvas-wrap"]}>
<canvas className={styles.scf__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}02 · 코너 프레임이 깜빡이며 인식을 기다리는 QR 코드 — 측정 누적면적 28.194% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var rand = seedRand(407025);
var dots = [];
for (var y = 0; y < SIZE; y += 1) {
for (var x = 0; x < SIZE; x += 1) {
if (rand() < 0.4) dots.push({ x: x, y: y });
}
}
function cornerGlow(dist) {
// 계산 한 겹: 스캔선까지 거리 → 코너 발광 강도(0~1), 범위 밖이면 0
var p = 1 - dist / SCF_CORNER_GLOW_RANGE_PX;
return p < 0 ? 0 : (p > 1 ? 1 : p);
}
function draw(elapsed) {
var cw = canvas.width, ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
var pad = 28;
var boxX = pad, boxY = pad, boxW = cw - pad * 2, boxH = ch - pad * 2;
측정: 누적면적 28.2%, 강도 평균 62.6, 23/23 프레임에서 움직였다.
03남은시간 비율이 그대로 반경 배율이 되는 펄스 링 QR
루프 안에서 남은시간 비율 값이 0.4~1 사이 배율로 바뀌어 매 파동의 최대 반경을 정하므로, 남은시간이 줄수록 링이 더 좁게 퍼진다. 쿠폰 QR 코드의 만료 임박을 링 크기 하나로 보여준다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 계산 한 겹: 루프 안 남은 유효시간 비율이 곧 그대로 링의 최대 반경 스케일이 된다
// (남은 시간이 줄수록 링이 더 작게 퍼진다 = 만료 임박 표시).
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();
측정: 누적면적 40.2%, 강도 평균 46.9, 23/23 프레임에서 움직였다.
04남은시간 값이 각도로 깎이는 만료 카운트다운 링
경과 비율 값이 0~360도 각도로 바뀌어 링을 시계 방향으로 깎아내고, 남은시간이 20% 아래로 내려가면 같은 urgency 값이 링 색·배경 톤·외곽 글로우를 한꺼번에 붉게 조정한다. 결제 QR 코드의 유효시간 카운트다운에 쓴다 — 데모의 30초는 시연 안에서만 빠르게 도는 가상 시계 배속을 곱한 결과다(뒤 "함정" 절에서 다룬다).

<main class="stage">
<div class="ecr is-demo" id="ecrRoot">
<div class="ecr__badge" id="ecrBadge">30초 남음</div>
<button class="ecr__ctrl" id="ecrCtrl" type="button">일시정지</button>
<div class="ecr__canvas-wrap">
<canvas class="ecr__canvas" id="ecrCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 경과 비율을 0~360도 각도로 바꿔 링을 깎아 나가고, 남은 비율이 20% 아래로
// 내려가면 그 비율만큼 링 색을 파랑에서 빨강으로 보간한다. .is-demo 는 실 30000ms
// 상수는 그대로 둔 채 시연 배속(DEMO_ACCEL_FACTOR)만 곱해 2초 측정창에 맞춘다.
@include stage($stage-ink);
.ecr {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ecr__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;
}
.ecr__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;
}
.ecr__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.ecr__canvas-wrap {
width: 100%;
height: 220px;
border-radius: $r-card;
background: rgba(255, 255, 255, .04);
display: grid;
place-items: center;
overflow: hidden;
}
.ecr__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.ecr { gap: $sp-1; }
.ecr__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.ecr__canvas-wrap { height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.ecr__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);
}
.ecr {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ecr__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;
}
.ecr__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;
}
.ecr__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ecr__canvas-wrap {
width: 100%;
height: 220px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
display: grid;
place-items: center;
overflow: hidden;
}
.ecr__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.ecr {
gap: 4px;
}
.ecr__badge {
padding: 4px 8px;
font-size: 10px;
}
.ecr__canvas-wrap {
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.ecr__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// QR_EXPIRE_DURATION_MS — 변형 1: 30000 → 15000 으로 늘리면 만료까지 더 여유 있게 카운트다운한다.
var QR_EXPIRE_DURATION_MS = 30000;
// 시연 전용 가상 시계 배속: 30000ms 만료를 2초 측정 창 안에서 보여주기 위한 시연 장치일 뿐,
// 상수 QR_EXPIRE_DURATION_MS 자체는 그대로 유지한다.
var DEMO_ACCEL_FACTOR = 15;
var canvas = document.getElementById('ecrCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var badge = document.getElementById('ecrBadge');
var ctrl = document.getElementById('ecrCtrl');
var root = document.getElementById('ecrRoot');
var isDemo = root.classList.contains('is-demo');
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(407041);
var dots = [];
for (var y = 0; y < 9; y += 1) {
for (var x = 0; x < 9; x += 1) {
if (rand() < 0.42) dots.push({ x: x, y: y });
}
}
function lerp(a, b, t) { return a + (b - a) * t; }
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 중심·링 반지름을 다시 잰다 —
// 링이 항상 스테이지 폭까지 채우게(278px 캡 없이 반응형).
var dpr = 1, cw = 240, ch = 200, cx = 120, cy = 100, radius = 70;
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;
radius = Math.min(cw, ch) * 0.44;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(virtualElapsed) {
lastElapsed = virtualElapsed;
ctx.clearRect(0, 0, cw, ch);
// 계산 한 겹: 경과 비율 → 각도(0~360) 로 링을 깎고, 만료 근접(20% 이하)일수록
// 파랑→빨강으로 색을 보간한다. 같은 urgency 값이 배경 톤·링 외곽 글로우에도 반영돼
// 화면 넓은 면이 함께 바뀐다.
var ratio = Math.min(1, virtualElapsed / QR_EXPIRE_DURATION_MS);
var remainRatio = 1 - ratio;
var angle = ratio * 360;
var urgency = remainRatio < 0.2 ? (0.2 - remainRatio) / 0.2 : 0;
var r1 = lerp(120, 255, urgency), g1 = lerp(200, 90, urgency), b1 = lerp(255, 90, urgency);
var ringColor = 'rgb(' + Math.round(r1) + ',' + Math.round(g1) + ',' + Math.round(b1) + ')';
// 카드 배경 밝기 — urgency 가 오를수록 전체 캔버스가 옅은 붉은 톤으로 밝아진다.
var bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(cw, ch) * 0.72);
bg.addColorStop(0, 'rgba(' + Math.round(lerp(40, 120, urgency)) + ',' + Math.round(lerp(30, 26, urgency)) + ',' + Math.round(lerp(48, 30, urgency)) + ',1)');
bg.addColorStop(1, 'rgba(18,20,30,1)');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, cw, ch);
var ringWidth = Math.max(6, cw * 0.034);
// 링 외곽 글로우 — urgency 가 오를수록 더 크고 옅게 번진다.
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = 'rgba(' + Math.round(r1) + ',' + Math.round(g1) + ',' + Math.round(b1) + ',' + (0.16 + urgency * 0.22).toFixed(3) + ')';
ctx.lineWidth = ringWidth * (2.4 + urgency * 1.6);
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (angle * Math.PI / 180));
ctx.stroke();
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = 'rgba(255,255,255,.12)';
ctx.lineWidth = ringWidth;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = ringColor;
ctx.lineWidth = ringWidth;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (angle * Math.PI / 180));
ctx.stroke();
var box = Math.min(cw, ch) * 0.31;
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)';
dots.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);
});
var remainSec = Math.max(0, Math.ceil((QR_EXPIRE_DURATION_MS - virtualElapsed) / 1000));
badge.textContent = remainSec + '초 남음';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var virtualElapsed = reduce ? QR_EXPIRE_DURATION_MS : 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) {
var accel = isDemo ? DEMO_ACCEL_FACTOR : 1;
virtualElapsed += dt * accel;
if (virtualElapsed >= QR_EXPIRE_DURATION_MS) virtualElapsed -= QR_EXPIRE_DURATION_MS;
}
draw(virtualElapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(virtualElapsed);
requestAnimationFrame(tick);
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
virtualElapsed = 0;
playing = true;
ctrl.textContent = '일시정지';
}
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./ExpiryCountdownRing.module.scss";
export default function ExpiryCountdownRing() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("30초 남음");
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_EXPIRE_DURATION_MS — 변형 1: 30000 → 15000 으로 늘리면 만료까지 더 여유 있게 카운트다운한다.
const QR_EXPIRE_DURATION_MS = 30000;
// 시연 전용 가상 시계 배속: 30000ms 만료를 2초 측정 창 안에서 보여주기 위한 시연 장치일 뿐,
// 상수 QR_EXPIRE_DURATION_MS 자체는 그대로 유지한다.
const DEMO_ACCEL_FACTOR = 15;
const isDemo = root.classList.contains("is-demo");
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(407041);
const dots: { x: number; y: number }[] = [];
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
if (rand() < 0.42) dots.push({ x, y });
}
}
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 중심·링 반지름을 다시 잰다 —
// 링이 항상 스테이지 폭까지 채우게(278px 캡 없이 반응형).
let dpr = 1, cw = 240, ch = 200, cx = 120, cy = 100, radius = 70;
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;
radius = Math.min(cw, ch) * 0.44;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(virtualElapsed: number) {
lastElapsed = virtualElapsed;
ctx!.clearRect(0, 0, cw, ch);
// 계산 한 겹: 경과 비율 → 각도(0~360) 로 링을 깎고, 만료 근접(20% 이하)일수록
// 파랑→빨강으로 색을 보간한다. 같은 urgency 값이 배경 톤·링 외곽 글로우에도 반영돼
// 화면 넓은 면이 함께 바뀐다.
const ratio = Math.min(1, virtualElapsed / QR_EXPIRE_DURATION_MS);
const remainRatio = 1 - ratio;
const angle = ratio * 360;
const urgency = remainRatio < 0.2 ? (0.2 - remainRatio) / 0.2 : 0;
const r1 = lerp(120, 255, urgency), g1 = lerp(200, 90, urgency), b1 = lerp(255, 90, urgency);
const ringColor = "rgb(" + Math.round(r1) + "," + Math.round(g1) + "," + Math.round(b1) + ")";
// 카드 배경 밝기 — urgency 가 오를수록 전체 캔버스가 옅은 붉은 톤으로 밝아진다.
const bg = ctx!.createRadialGradient(cx, cy, 0, cx, cy, Math.max(cw, ch) * 0.72);
bg.addColorStop(0, "rgba(" + Math.round(lerp(40, 120, urgency)) + "," + Math.round(lerp(30, 26, urgency)) + "," + Math.round(lerp(48, 30, urgency)) + ",1)");
bg.addColorStop(1, "rgba(18,20,30,1)");
ctx!.fillStyle = bg;
ctx!.fillRect(0, 0, cw, ch);
const ringWidth = Math.max(6, cw * 0.034);
// 링 외곽 글로우 — urgency 가 오를수록 더 크고 옅게 번진다.
ctx!.globalCompositeOperation = "lighter";
ctx!.strokeStyle = "rgba(" + Math.round(r1) + "," + Math.round(g1) + "," + Math.round(b1) + "," + (0.16 + urgency * 0.22).toFixed(3) + ")";
ctx!.lineWidth = ringWidth * (2.4 + urgency * 1.6);
ctx!.beginPath();
ctx!.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (angle * Math.PI) / 180);
ctx!.stroke();
ctx!.globalCompositeOperation = "source-over";
ctx!.strokeStyle = "rgba(255,255,255,.12)";
ctx!.lineWidth = ringWidth;
ctx!.beginPath();
ctx!.arc(cx, cy, radius, 0, Math.PI * 2);
ctx!.stroke();
ctx!.strokeStyle = ringColor;
ctx!.lineWidth = ringWidth;
ctx!.lineCap = "round";
ctx!.beginPath();
ctx!.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (angle * Math.PI) / 180);
ctx!.stroke();
const box = Math.min(cw, ch) * 0.31;
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)";
dots.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);
});
const remainSec = Math.max(0, Math.ceil((QR_EXPIRE_DURATION_MS - virtualElapsed) / 1000));
setBadge(remainSec + "초 남음");
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let virtualElapsed = reduce ? QR_EXPIRE_DURATION_MS : 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) {
const accel = isDemo ? DEMO_ACCEL_FACTOR : 1;
virtualElapsed += dt * accel;
if (virtualElapsed >= QR_EXPIRE_DURATION_MS) virtualElapsed -= QR_EXPIRE_DURATION_MS;
}
draw(virtualElapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(virtualElapsed);
raf = requestAnimationFrame(tick);
function onClick() {
playing = !playing;
setCtrlLabel(playing ? "일시정지" : "재생");
}
function onKeydown(e: KeyboardEvent) {
if ((e.key === "r" || e.key === "R") && !reduce) {
virtualElapsed = 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.ecr} is-demo`} ref={rootRef}>
<div className={styles.ecr__badge}>{badge}</div>
<button className={styles.ecr__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["ecr__canvas-wrap"]}>
<canvas className={styles.ecr__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}04 · 남은시간 값이 각도 값으로 깎이는 카운트다운 링 — 측정 누적면적 61.73% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 계산 한 겹: 경과 비율 → 각도(0~360) 로 링을 깎고, 만료 근접(20% 이하)일수록
// 파랑→빨강으로 색을 보간한다. 같은 urgency 값이 배경 톤·링 외곽 글로우에도 반영돼
// 화면 넓은 면이 함께 바뀐다.
var ratio = Math.min(1, virtualElapsed / QR_EXPIRE_DURATION_MS);
var remainRatio = 1 - ratio;
var angle = ratio * 360;
var urgency = remainRatio < 0.2 ? (0.2 - remainRatio) / 0.2 : 0;
var r1 = lerp(120, 255, urgency), g1 = lerp(200, 90, urgency), b1 = lerp(255, 90, urgency);
var ringColor = 'rgb(' + Math.round(r1) + ',' + Math.round(g1) + ',' + Math.round(b1) + ')';
// 카드 배경 밝기 — urgency 가 오를수록 전체 캔버스가 옅은 붉은 톤으로 밝아진다.
var bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(cw, ch) * 0.72);
bg.addColorStop(0, 'rgba(' + Math.round(lerp(40, 120, urgency)) + ',' + Math.round(lerp(30, 26, urgency)) + ',' + Math.round(lerp(48, 30, urgency)) + ',1)');
bg.addColorStop(1, 'rgba(18,20,30,1)');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, cw, ch);
var ringWidth = Math.max(6, cw * 0.034);
// 링 외곽 글로우 — urgency 가 오를수록 더 크고 옅게 번진다.
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = 'rgba(' + Math.round(r1) + ',' + Math.round(g1) + ',' + Math.round(b1) + ',' + (0.16 + urgency * 0.22).toFixed(3) + ')';
ctx.lineWidth = ringWidth * (2.4 + urgency * 1.6);
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (angle * Math.PI / 180));
측정: 누적면적 61.7%, 강도 평균 81.0, 23/23 프레임에서 움직였다.
05인식 판정 값이 스프링 곡선으로 튕기는 체크 QR
스캔 인식 시점부터 지난 시간을 감쇠 코사인 함수 하나에 넣어, 목표 크기를 살짝 넘었다가 되돌아오는 스프링 스케일 값을 만든다. 티켓 QR 코드가 인식되는 순간 체크 배지가 통통 튀며 완료를 알린다.

<main class="stage">
<div class="ssc is-demo" id="sscRoot">
<div class="ssc__badge" id="sscBadge">스캔 대기</div>
<button class="ssc__ctrl" id="sscCtrl" type="button">일시정지</button>
<div class="ssc__canvas-wrap">
<canvas class="ssc__canvas" id="sscCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 팝 단계의 배지 스케일을 감쇠 코사인 근사(스프링 오버슛)로 계산한다 —
// 목표 크기를 살짝 넘겼다가 제자리로 돌아오는 단일 식이라 CSS 트윈보다 자연스럽다.
@include stage($stage-ink);
.ssc {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ssc__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;
}
.ssc__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;
}
.ssc__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.ssc__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;
}
.ssc__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.ssc { gap: $sp-1; }
.ssc__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.ssc__canvas-wrap { max-width: 150px; aspect-ratio: 3 / 2; margin: 0 auto; }
}
@media (prefers-reduced-motion: reduce) {
.ssc__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);
}
.ssc {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ssc__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;
}
.ssc__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;
}
.ssc__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ssc__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;
}
.ssc__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.ssc {
gap: 4px;
}
.ssc__badge {
padding: 4px 8px;
font-size: 10px;
}
.ssc__canvas-wrap {
max-width: 150px;
aspect-ratio: 3/2;
margin: 0 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.ssc__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// SUCCESS_CHECK_SPRING_OVERSHOOT — 변형 1: 0.18 → 0.32 로 늘리면 체크 배지가 더 크게 튕긴다.
var SUCCESS_CHECK_SPRING_OVERSHOOT = 0.18;
var LOOP_MS = 2000;
var DRAW_START = 200, DRAW_END = 620;
var POP_START = 560, POP_END = 980;
var canvas = document.getElementById('sscCanvas');
var ctx = canvas.getContext('2d');
var badge = document.getElementById('sscBadge');
var ctrl = document.getElementById('sscCtrl');
var root = document.getElementById('sscRoot');
// 계산 한 겹: 감쇠 스프링 근사 — overshoot 비율만큼 목표를 넘었다가 되돌아오는
// 단일 코사인 감쇠식으로 배지 스케일을 만든다 (linear tween 아님).
function springScale(p, overshoot) {
if (p <= 0) return 0;
if (p >= 1) return 1;
var decay = Math.exp(-4.5 * p);
return 1 + overshoot * decay * Math.cos(p * Math.PI * 2.2);
}
function clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }
function draw(elapsed) {
var cw = canvas.width, ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
var cx = cw / 2, cy = ch / 2;
var drawP = clamp01((elapsed - DRAW_START) / (DRAW_END - DRAW_START));
var popP = clamp01((elapsed - POP_START) / (POP_END - POP_START));
var box = 70;
ctx.strokeStyle = 'rgba(255,255,255,.14)';
ctx.lineWidth = 2;
ctx.strokeRect(cx - box / 2, cy - box / 2, box, box);
if (drawP > 0 && popP < 1) {
ctx.save();
ctx.strokeStyle = 'rgba(120,200,255,.9)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(cx - box / 2 + 6, cy - box / 2 + drawP * (box - 12) + 6);
ctx.stroke();
ctx.restore();
}
if (popP > 0) {
var scale = springScale(popP, SUCCESS_CHECK_SPRING_OVERSHOOT);
var r = 40 * scale;
ctx.fillStyle = 'rgba(120,220,170,.92)';
ctx.beginPath();
ctx.arc(cx, cy, Math.max(0, r), 0, Math.PI * 2);
ctx.fill();
if (r > 8) {
ctx.strokeStyle = 'rgb(20,26,38)';
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(cx - 14, cy + 1);
ctx.lineTo(cx - 4, cy + 12);
ctx.lineTo(cx + 16, cy - 12);
ctx.stroke();
}
}
if (popP >= 1) {
badge.textContent = '스캔 완료';
} else if (drawP > 0) {
badge.textContent = '확인 중 ' + Math.round(drawP * 100) + '%';
} else {
badge.textContent = '스캔 대기';
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? POP_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 "./ScanSuccessCheckmark.module.scss";
export default function ScanSuccessCheckmark() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("스캔 대기");
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;
// SUCCESS_CHECK_SPRING_OVERSHOOT — 변형 1: 0.18 → 0.32 로 늘리면 체크 배지가 더 크게 튕긴다.
const SUCCESS_CHECK_SPRING_OVERSHOOT = 0.18;
const LOOP_MS = 2000;
const DRAW_START = 200, DRAW_END = 620;
const POP_START = 560, POP_END = 980;
// 계산 한 겹: 감쇠 스프링 근사 — overshoot 비율만큼 목표를 넘었다가 되돌아오는
// 단일 코사인 감쇠식으로 배지 스케일을 만든다 (linear tween 아님).
function springScale(p: number, overshoot: number) {
if (p <= 0) return 0;
if (p >= 1) return 1;
const decay = Math.exp(-4.5 * p);
return 1 + overshoot * decay * Math.cos(p * Math.PI * 2.2);
}
function clamp01(v: number) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function draw(elapsed: number) {
const cw = canvas!.width, ch = canvas!.height;
ctx!.clearRect(0, 0, cw, ch);
const cx = cw / 2, cy = ch / 2;
const drawP = clamp01((elapsed - DRAW_START) / (DRAW_END - DRAW_START));
const popP = clamp01((elapsed - POP_START) / (POP_END - POP_START));
const box = 70;
ctx!.strokeStyle = "rgba(255,255,255,.14)";
ctx!.lineWidth = 2;
ctx!.strokeRect(cx - box / 2, cy - box / 2, box, box);
if (drawP > 0 && popP < 1) {
ctx!.save();
ctx!.strokeStyle = "rgba(120,200,255,.9)";
ctx!.lineWidth = 3;
ctx!.beginPath();
ctx!.moveTo(cx - box / 2 + 6, cy - box / 2 + drawP * (box - 12) + 6);
ctx!.stroke();
ctx!.restore();
}
if (popP > 0) {
const scale = springScale(popP, SUCCESS_CHECK_SPRING_OVERSHOOT);
const r = 40 * scale;
ctx!.fillStyle = "rgba(120,220,170,.92)";
ctx!.beginPath();
ctx!.arc(cx, cy, Math.max(0, r), 0, Math.PI * 2);
ctx!.fill();
if (r > 8) {
ctx!.strokeStyle = "rgb(20,26,38)";
ctx!.lineWidth = 4;
ctx!.lineCap = "round";
ctx!.lineJoin = "round";
ctx!.beginPath();
ctx!.moveTo(cx - 14, cy + 1);
ctx!.lineTo(cx - 4, cy + 12);
ctx!.lineTo(cx + 16, cy - 12);
ctx!.stroke();
}
}
if (popP >= 1) {
setBadge("스캔 완료");
} else if (drawP > 0) {
setBadge("확인 중 " + Math.round(drawP * 100) + "%");
} else {
setBadge("스캔 대기");
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? POP_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.ssc} is-demo`} ref={rootRef}>
<div className={styles.ssc__badge}>{badge}</div>
<button className={styles.ssc__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["ssc__canvas-wrap"]}>
<canvas className={styles.ssc__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}05 · 인식되는 순간 체크 모양으로 접히는 QR 코드 — 측정 누적면적 6.892% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 계산 한 겹: 감쇠 스프링 근사 — overshoot 비율만큼 목표를 넘었다가 되돌아오는
// 단일 코사인 감쇠식으로 배지 스케일을 만든다 (linear tween 아님).
function springScale(p, overshoot) {
if (p <= 0) return 0;
if (p >= 1) return 1;
var decay = Math.exp(-4.5 * p);
return 1 + overshoot * decay * Math.cos(p * Math.PI * 2.2);
}
function clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }
function draw(elapsed) {
var cw = canvas.width, ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
var cx = cw / 2, cy = ch / 2;
var drawP = clamp01((elapsed - DRAW_START) / (DRAW_END - DRAW_START));
var popP = clamp01((elapsed - POP_START) / (POP_END - POP_START));
측정: 누적면적 6.9%, 강도 평균 119.7, 14/23 프레임에서 움직였다.
06진행 값이 커질수록 흔들림이 잦아드는 생성 QR
생성 진행 값이 오를수록 지수 감쇠로 줄어드는 진폭 값 하나가 모듈마다 다른 위상·방향의 흔들림을 만들어, 노이즈처럼 떨리던 격자가 점점 자리를 잡는다. 신규 QR 코드 발급 애니메이션에 쓴다.

<main class="stage">
<div class="gns is-demo" id="gnsRoot">
<div class="gns__badge" id="gnsBadge">코드 생성 중</div>
<button class="gns__ctrl" id="gnsCtrl" type="button">일시정지</button>
<div class="gns__canvas-wrap">
<canvas class="gns__canvas" id="gnsCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 모듈별 위상·방향으로 흔드는 노이즈 진폭이 시간에 따라 지수 감쇠해
// 0으로 수렴한다 — 흔들리던 픽셀이 점점 제자리로 가라앉는다.
@include stage($stage-ink);
.gns {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.gns__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;
}
.gns__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;
}
.gns__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.gns__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;
}
.gns__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.gns { gap: $sp-1; }
.gns__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.gns__canvas-wrap { max-width: 150px; aspect-ratio: 3 / 2; margin: 0 auto; }
}
@media (prefers-reduced-motion: reduce) {
.gns__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);
}
.gns {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.gns__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;
}
.gns__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;
}
.gns__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.gns__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;
}
.gns__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.gns {
gap: 4px;
}
.gns__badge {
padding: 4px 8px;
font-size: 10px;
}
.gns__canvas-wrap {
max-width: 150px;
aspect-ratio: 3/2;
margin: 0 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.gns__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// QR_NOISE_SETTLE_AMPLITUDE_PX — 변형 1: 6 → 2 로 늘리면 초기 흔들림 폭이 더 커진다(전값이 초기값).
var QR_NOISE_SETTLE_AMPLITUDE_PX = 6;
var LOOP_MS = 2000;
var SETTLE_MS = 1500;
var SIZE = 13;
var canvas = document.getElementById('gnsCanvas');
var ctx = canvas.getContext('2d');
var badge = document.getElementById('gnsBadge');
var ctrl = document.getElementById('gnsCtrl');
var root = document.getElementById('gnsRoot');
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(407060);
var modules = [];
for (var y = 0; y < SIZE; y += 1) {
for (var x = 0; x < SIZE; x += 1) {
if (rand() < 0.45) {
modules.push({
x: x, y: y,
phase: rand() * Math.PI * 2,
freq: 6 + rand() * 4,
dir: rand() * Math.PI * 2
});
}
}
}
function draw(elapsed) {
var cw = canvas.width, ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
var box = Math.min(cw, ch) - 40;
var cell = box / SIZE;
var ox = (cw - box) / 2, oy = (ch - box) / 2;
// 계산 한 겹: 진폭이 시간에 따라 지수 감쇠 — 초기 QR_NOISE_SETTLE_AMPLITUDE_PX 에서
// SETTLE_MS 동안 0으로 잦아들며, 모듈마다 다른 위상·방향으로 흔들려 자리를 잡는다.
var settleP = Math.min(1, elapsed / SETTLE_MS);
var amp = QR_NOISE_SETTLE_AMPLITUDE_PX * Math.exp(-4 * settleP);
ctx.fillStyle = 'rgb(255,247,230)';
modules.forEach(function (m) {
var wobble = Math.sin(elapsed / 1000 * m.freq + m.phase) * amp;
var jx = Math.cos(m.dir) * wobble;
var jy = Math.sin(m.dir) * wobble;
var s = cell * 0.68;
var cx = ox + m.x * cell + cell / 2 + jx;
var cy = oy + m.y * cell + cell / 2 + jy;
ctx.globalAlpha = 0.55 + 0.45 * settleP;
ctx.fillRect(cx - s / 2, cy - s / 2, s, s);
});
ctx.globalAlpha = 1;
badge.textContent = settleP >= 1 ? '코드 생성 완료' : '코드 생성 중 ' + Math.round(settleP * 100) + '%';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? SETTLE_MS : 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 "./GenerateNoiseSettle.module.scss";
export default function GenerateNoiseSettle() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("코드 생성 중");
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_NOISE_SETTLE_AMPLITUDE_PX — 변형 1: 6 → 2 로 늘리면 초기 흔들림 폭이 더 커진다(전값이 초기값).
const QR_NOISE_SETTLE_AMPLITUDE_PX = 6;
const LOOP_MS = 2000;
const SETTLE_MS = 1500;
const SIZE = 13;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const rand = seedRand(407060);
const modules: { x: number; y: number; phase: number; freq: number; dir: number }[] = [];
for (let y = 0; y < SIZE; y += 1) {
for (let x = 0; x < SIZE; x += 1) {
if (rand() < 0.45) {
modules.push({
x, y,
phase: rand() * Math.PI * 2,
freq: 6 + rand() * 4,
dir: rand() * Math.PI * 2,
});
}
}
}
function draw(elapsed: number) {
const cw = canvas!.width, ch = canvas!.height;
ctx!.clearRect(0, 0, cw, ch);
const box = Math.min(cw, ch) - 40;
const cell = box / SIZE;
const ox = (cw - box) / 2, oy = (ch - box) / 2;
// 계산 한 겹: 진폭이 시간에 따라 지수 감쇠 — 초기 QR_NOISE_SETTLE_AMPLITUDE_PX 에서
// SETTLE_MS 동안 0으로 잦아들며, 모듈마다 다른 위상·방향으로 흔들려 자리를 잡는다.
const settleP = Math.min(1, elapsed / SETTLE_MS);
const amp = QR_NOISE_SETTLE_AMPLITUDE_PX * Math.exp(-4 * settleP);
ctx!.fillStyle = "rgb(255,247,230)";
modules.forEach((m) => {
const wobble = Math.sin((elapsed / 1000) * m.freq + m.phase) * amp;
const jx = Math.cos(m.dir) * wobble;
const jy = Math.sin(m.dir) * wobble;
const s = cell * 0.68;
const cx = ox + m.x * cell + cell / 2 + jx;
const cy = oy + m.y * cell + cell / 2 + jy;
ctx!.globalAlpha = 0.55 + 0.45 * settleP;
ctx!.fillRect(cx - s / 2, cy - s / 2, s, s);
});
ctx!.globalAlpha = 1;
setBadge(settleP >= 1 ? "코드 생성 완료" : "코드 생성 중 " + Math.round(settleP * 100) + "%");
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? SETTLE_MS : 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.gns} is-demo`} ref={rootRef}>
<div className={styles.gns__badge}>{badge}</div>
<button className={styles.gns__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["gns__canvas-wrap"]}>
<canvas className={styles.gns__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}06 · 흔들림 폭이 줄며 자리 잡는 생성 중 QR 코드 — 측정 누적면적 10.958% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 계산 한 겹: 진폭이 시간에 따라 지수 감쇠 — 초기 QR_NOISE_SETTLE_AMPLITUDE_PX 에서
// SETTLE_MS 동안 0으로 잦아들며, 모듈마다 다른 위상·방향으로 흔들려 자리를 잡는다.
var settleP = Math.min(1, elapsed / SETTLE_MS);
var amp = QR_NOISE_SETTLE_AMPLITUDE_PX * Math.exp(-4 * settleP);
ctx.fillStyle = 'rgb(255,247,230)';
modules.forEach(function (m) {
var wobble = Math.sin(elapsed / 1000 * m.freq + m.phase) * amp;
var jx = Math.cos(m.dir) * wobble;
var jy = Math.sin(m.dir) * wobble;
var s = cell * 0.68;
var cx = ox + m.x * cell + cell / 2 + jx;
var cy = oy + m.y * cell + cell / 2 + jy;
ctx.globalAlpha = 0.55 + 0.45 * settleP;
ctx.fillRect(cx - s / 2, cy - s / 2, s, s);
});
ctx.globalAlpha = 1;
badge.textContent = settleP >= 1 ? '코드 생성 완료' : '코드 생성 중 ' + Math.round(settleP * 100) + '%';
측정: 누적면적 11.0%, 강도 평균 44.7, 23/23 프레임에서 움직였다.
07자릿수 지연 값이 시차를 두고 넘어가는 금액 패널
결제 금액이 바뀔 때마다 각 자릿수가 자리 순서만큼 지연 값을 받아, 그 값이 지난 자릿수부터 차례로 세로로 굴러 이전 숫자에서 새 숫자로 넘어간다. 가맹점 결제 금액 실시간 표시에 쓴다.

<main class="stage">
<div class="ars is-demo" id="arsRoot">
<div class="ars__badge" id="arsBadge">결제 금액</div>
<button class="ars__ctrl" id="arsCtrl" type="button">일시정지</button>
<div class="ars__panel">
<div class="ars__sweep" id="arsSweep" aria-hidden="true"></div>
<div class="ars__digits" id="arsDigits"></div>
<span class="ars__unit">원</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 자릿수 인덱스(왼쪽부터)에 스태거 값을 곱해 컬럼별 시작 지연을 구하고,
// back-out 이징으로 되돌아오는 오버슛과 함께 각 자릿수가 순서대로 굴러 올라간다.
@include stage($stage-ink);
.ars {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ars__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;
}
.ars__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;
}
.ars__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.ars__panel {
position: relative;
display: flex;
align-items: baseline;
gap: $sp-2;
padding: $sp-5 $sp-6;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
box-shadow: $shadow-raised;
overflow: hidden;
}
// 계산된 하이라이트 sweep — 폭 240%(-120%~120%)로 대각선 가로지름, 불투명도·위치는 JS 가 롤 진행률로 제어
.ars__sweep {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(100deg, transparent 30%, rgba($subject-blue-light, .35) 50%, transparent 70%);
will-change: transform, opacity;
}
.ars__digits {
position: relative;
display: flex;
}
.ars__col {
position: relative;
width: 22px;
height: 40px;
overflow: hidden;
&--comma {
width: 10px;
}
}
.ars__track {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
height: 200%;
transform: translateY(0%);
will-change: transform;
}
.ars__d {
height: 50%;
display: grid;
place-items: center;
font-weight: 800;
font-size: 26px;
font-variant-numeric: tabular-nums;
color: $subject-cream;
}
.ars__unit {
color: $subject-cream;
font-weight: 700;
font-size: 16px;
opacity: .8;
}
@media (max-width: 340px) {
.ars__panel { padding: $sp-4 $sp-5; }
.ars__col { width: 18px; height: 32px; }
.ars__col--comma { width: 8px; }
.ars__d { font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.ars__track { transition: none !important; }
}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);
}
.ars {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ars__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ars__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;
}
.ars__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ars__panel {
position: relative;
display: flex;
align-items: baseline;
gap: 8px;
padding: 20px 24px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.ars__sweep {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(100deg, transparent 30%, rgba(91, 140, 248, 0.35) 50%, transparent 70%);
will-change: transform, opacity;
}
.ars__digits {
position: relative;
display: flex;
}
.ars__col {
position: relative;
width: 22px;
height: 40px;
overflow: hidden;
}
.ars__col--comma {
width: 10px;
}
.ars__track {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
height: 200%;
transform: translateY(0%);
will-change: transform;
}
.ars__d {
height: 50%;
display: grid;
place-items: center;
font-weight: 800;
font-size: 26px;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.ars__unit {
color: #fff7e6;
font-weight: 700;
font-size: 16px;
opacity: 0.8;
}
@media (max-width: 340px) {
.ars__panel {
padding: 16px 20px;
}
.ars__col {
width: 18px;
height: 32px;
}
.ars__col--comma {
width: 8px;
}
.ars__d {
font-size: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.ars__track {
transition: none !important;
}
}(function () {
// AMOUNT_ROLL_DIGIT_STAGGER_MS — 변형 1: 60 → 110 으로 늘리면 자릿수가 더 크게 어긋나며 굴러간다.
var AMOUNT_ROLL_DIGIT_STAGGER_MS = 60;
var LOOP_MS = 2000;
var ROLL_MS = 480;
var FROM = '08,000';
var TO = '12,400';
// 시연 스케줄: 2초 루프 안에 굴러가는 방향을 두 번 넣는다(증가 → 감소) — 로직·상수는 그대로,
// 두 번째 구간은 같은 트랙(FROM/TO 두 자릿수)을 반대로 재생해 되돌아가는 롤을 보여준다.
var SEG_DURATION = (TO.length - 1) * AMOUNT_ROLL_DIGIT_STAGGER_MS + ROLL_MS;
var SEG2_START = 1050;
var root = document.getElementById('arsRoot');
var badge = document.getElementById('arsBadge');
var ctrl = document.getElementById('arsCtrl');
var digitsEl = document.getElementById('arsDigits');
var sweepEl = document.getElementById('arsSweep');
var cols = [];
for (var i = 0; i < TO.length; i += 1) {
var col = document.createElement('span');
col.className = 'ars__col';
if (TO[i] === ',') col.classList.add('ars__col--comma');
var track = document.createElement('span');
track.className = 'ars__track';
var a = document.createElement('span'); a.className = 'ars__d'; a.textContent = FROM[i];
var b = document.createElement('span'); b.className = 'ars__d'; b.textContent = TO[i];
track.appendChild(a); track.appendChild(b);
col.appendChild(track);
digitsEl.appendChild(col);
// 계산 한 겹: 자릿수 인덱스(왼쪽부터)에 스태거를 곱해 각 컬럼의 시작 지연을 구한다.
cols.push({ track: track, delay: i * AMOUNT_ROLL_DIGIT_STAGGER_MS, isDigit: TO[i] !== ',' });
}
var maxDelay = (TO.length - 1) * AMOUNT_ROLL_DIGIT_STAGGER_MS;
var ROLL_END = maxDelay + ROLL_MS;
function easeOutBack(t) {
var c1 = 1.4, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function progressFor(elapsed, segStart, delay) {
var local = elapsed - segStart - delay;
if (local <= 0) return 0;
if (local >= ROLL_MS) return 1;
return local / ROLL_MS;
}
function render(elapsed) {
var anyMoving = false;
// 계산 한 겹: 두 번째 구간(감소 방향)이 시작됐는지는 컬럼별 지연 없이 구간 전체로 판정한다 —
// 첫 구간 꼬리와 겹치지 않도록 SEG2_START 를 첫 구간 최대 종료(ROLL_END)보다 뒤에 둔다.
var seg2Active = elapsed >= SEG2_START;
cols.forEach(function (c) {
var translateY, p;
if (seg2Active) {
var p2 = progressFor(elapsed, SEG2_START, c.delay);
var eased2 = easeOutBack(p2);
translateY = -(1 - eased2) * 50;
p = p2;
} else {
var p1 = progressFor(elapsed, 0, c.delay);
var eased1 = easeOutBack(p1);
translateY = -eased1 * 50;
p = p1;
}
if (p > 0 && p < 1) anyMoving = true;
c.track.style.transform = 'translateY(' + translateY.toFixed(2) + '%)';
// 계산 한 겹: 롤 속도(사인 피크, 구간 중앙에서 최대)에 비례한 잔상 톤 — 형태는 바꾸지 않는다.
var velocity = Math.sin(Math.min(1, Math.max(0, p)) * Math.PI);
c.track.style.filter = velocity > 0.01 ? 'blur(' + (velocity * 1.1).toFixed(2) + 'px)' : 'none';
c.track.style.boxShadow = velocity > 0.01
? '0 0 ' + (velocity * 9).toFixed(1) + 'px rgba(91, 140, 248, ' + (velocity * 0.5).toFixed(2) + ')'
: 'none';
});
badge.textContent = anyMoving ? '갱신 중' : '결제 금액';
// 계산 한 겹: 카드 하이라이트 sweep — 롤 진행률(globalP)의 함수로 패널 전체를 가로지른다.
var globalP = -1;
if (elapsed >= 0 && elapsed < SEG_DURATION) {
globalP = elapsed / SEG_DURATION;
} else if (elapsed >= SEG2_START && elapsed < SEG2_START + SEG_DURATION) {
globalP = (elapsed - SEG2_START) / SEG_DURATION;
}
if (globalP >= 0) {
var sweepOpacity = Math.sin(globalP * Math.PI);
sweepEl.style.opacity = (sweepOpacity * 0.8).toFixed(2);
sweepEl.style.transform = 'translateX(' + (-120 + globalP * 240).toFixed(1) + '%)';
} else {
sweepEl.style.opacity = '0';
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? ROLL_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;
}
render(elapsed);
requestAnimationFrame(tick);
}
render(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 "./AmountRollSwap.module.scss";
export default function AmountRollSwap() {
const rootRef = useRef<HTMLDivElement>(null);
const digitsRef = useRef<HTMLDivElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const sweepRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("결제 금액");
const [ctrlLabel, setCtrlLabel] = useState("일시정지");
useEffect(() => {
const root = rootRef.current;
const digitsEl = digitsRef.current;
const ctrl = ctrlRef.current;
const sweepEl = sweepRef.current;
if (!root || !digitsEl || !ctrl || !sweepEl) return;
// AMOUNT_ROLL_DIGIT_STAGGER_MS — 변형 1: 60 → 110 으로 늘리면 자릿수가 더 크게 어긋나며 굴러간다.
const AMOUNT_ROLL_DIGIT_STAGGER_MS = 60;
const LOOP_MS = 2000;
const ROLL_MS = 480;
const FROM = "08,000";
const TO = "12,400";
// 시연 스케줄: 2초 루프 안에 굴러가는 방향을 두 번 넣는다(증가 → 감소) — 로직·상수는 그대로,
// 두 번째 구간은 같은 트랙(FROM/TO 두 자릿수)을 반대로 재생해 되돌아가는 롤을 보여준다.
const SEG_DURATION = (TO.length - 1) * AMOUNT_ROLL_DIGIT_STAGGER_MS + ROLL_MS;
const SEG2_START = 1050;
digitsEl.innerHTML = "";
const cols: { track: HTMLSpanElement; delay: number }[] = [];
for (let i = 0; i < TO.length; i += 1) {
const col = document.createElement("span");
col.className = styles.ars__col;
if (TO[i] === ",") col.classList.add(styles["ars__col--comma"]);
const track = document.createElement("span");
track.className = styles.ars__track;
const a = document.createElement("span"); a.className = styles.ars__d; a.textContent = FROM[i];
const b = document.createElement("span"); b.className = styles.ars__d; b.textContent = TO[i];
track.appendChild(a); track.appendChild(b);
col.appendChild(track);
digitsEl.appendChild(col);
// 계산 한 겹: 자릿수 인덱스(왼쪽부터)에 스태거를 곱해 각 컬럼의 시작 지연을 구한다.
cols.push({ track, delay: i * AMOUNT_ROLL_DIGIT_STAGGER_MS });
}
function easeOutBack(t: number) {
const c1 = 1.4, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function progressFor(elapsed: number, segStart: number, delay: number) {
const local = elapsed - segStart - delay;
if (local <= 0) return 0;
if (local >= ROLL_MS) return 1;
return local / ROLL_MS;
}
function render(elapsed: number) {
let anyMoving = false;
const seg2Active = elapsed >= SEG2_START;
cols.forEach((c) => {
let translateY: number;
let p: number;
if (seg2Active) {
const p2 = progressFor(elapsed, SEG2_START, c.delay);
const eased2 = easeOutBack(p2);
translateY = -(1 - eased2) * 50;
p = p2;
} else {
const p1 = progressFor(elapsed, 0, c.delay);
const eased1 = easeOutBack(p1);
translateY = -eased1 * 50;
p = p1;
}
if (p > 0 && p < 1) anyMoving = true;
c.track.style.transform = "translateY(" + translateY.toFixed(2) + "%)";
// 계산 한 겹: 롤 속도(사인 피크, 구간 중앙에서 최대)에 비례한 잔상 톤 — 형태는 바꾸지 않는다.
const velocity = Math.sin(Math.min(1, Math.max(0, p)) * Math.PI);
c.track.style.filter = velocity > 0.01 ? "blur(" + (velocity * 1.1).toFixed(2) + "px)" : "none";
c.track.style.boxShadow = velocity > 0.01
? "0 0 " + (velocity * 9).toFixed(1) + "px rgba(91, 140, 248, " + (velocity * 0.5).toFixed(2) + ")"
: "none";
});
setBadge(anyMoving ? "갱신 중" : "결제 금액");
// 계산 한 겹: 카드 하이라이트 sweep — 롤 진행률(globalP)의 함수로 패널 전체를 가로지른다.
let globalP = -1;
if (elapsed >= 0 && elapsed < SEG_DURATION) {
globalP = elapsed / SEG_DURATION;
} else if (elapsed >= SEG2_START && elapsed < SEG2_START + SEG_DURATION) {
globalP = (elapsed - SEG2_START) / SEG_DURATION;
}
if (globalP >= 0) {
const sweepOpacity = Math.sin(globalP * Math.PI);
sweepEl!.style.opacity = (sweepOpacity * 0.8).toFixed(2);
sweepEl!.style.transform = "translateX(" + (-120 + globalP * 240).toFixed(1) + "%)";
} else {
sweepEl!.style.opacity = "0";
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? SEG2_START + SEG_DURATION : 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;
}
render(elapsed);
raf = requestAnimationFrame(tick);
}
render(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);
digitsEl.innerHTML = "";
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.ars} is-demo`} ref={rootRef}>
<div className={styles.ars__badge}>{badge}</div>
<button className={styles.ars__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles.ars__panel}>
<div className={styles.ars__sweep} ref={sweepRef} aria-hidden="true" />
<div className={styles.ars__digits} ref={digitsRef} />
<span className={styles.ars__unit}>원</span>
</div>
</div>
</main>
);
}07 · 결제 금액 자릿수가 굴러 바뀌는 QR 코드 패널 — 측정 누적면적 11.454% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function easeOutBack(t) {
var c1 = 1.4, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function progressFor(elapsed, segStart, delay) {
var local = elapsed - segStart - delay;
if (local <= 0) return 0;
if (local >= ROLL_MS) return 1;
return local / ROLL_MS;
}
function render(elapsed) {
var anyMoving = false;
// 계산 한 겹: 두 번째 구간(감소 방향)이 시작됐는지는 컬럼별 지연 없이 구간 전체로 판정한다 —
// 첫 구간 꼬리와 겹치지 않도록 SEG2_START 를 첫 구간 최대 종료(ROLL_END)보다 뒤에 둔다.
var seg2Active = elapsed >= SEG2_START;
cols.forEach(function (c) {
측정: 누적면적 11.5%, 강도 평균 74.0, 20/23 프레임에서 움직였다.
08남은시간 값이 0에 닿는 순간 커지는 흔들림 QR
남은시간 비율이 0에 닿는 순간부터 지난 시간을 감쇠 사인파 하나에 넣어 흔들림 값을 만들고, 같은 값의 크기를 그림자 번짐·배경 톤에도 그대로 퍼뜨린다. 만료된 결제 QR 코드에서 재발급을 유도할 때 쓴다.

<main class="stage">
<div class="esr is-demo" id="esrRoot">
<div class="esr__badge" id="esrBadge">코드 유효</div>
<button class="esr__ctrl" id="esrCtrl" type="button">일시정지</button>
<div class="esr__panel" id="esrPanel">
<div class="esr__code" id="esrCode">QR</div>
<div class="esr__msg" id="esrMsg">스캔해 주세요</div>
<button class="esr__retry" id="esrRetry" type="button">다시 받기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 만료 시점부터 진동수·지수 감쇠를 곱한 감쇠 사인파로 패널을 좌우로 흔든다 —
// 일정 진폭에서 시작해 자연스럽게 잦아드는 진짜 물리 흔들림이다.
@include stage($stage-ink);
.esr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.esr__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;
}
.esr__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;
}
.esr__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.esr__panel {
width: 100%;
max-width: 220px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
padding: $sp-5;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
box-shadow: $shadow-raised;
will-change: transform;
transition: background-color $dur-quick $ease-out;
&--expired {
background: rgba(255, 100, 100, .08);
}
}
.esr__code {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: $r-sm;
background: rgba(20, 26, 38, .9);
color: $subject-cream;
font-weight: 800;
font-size: 20px;
transition: color $dur-quick $ease-out;
.esr__panel--expired & {
color: $error;
}
}
.esr__msg {
color: $subject-cream;
font-weight: 600;
font-size: 12px;
text-align: center;
}
.esr__retry {
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-4;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $dur-quick $ease-out;
.esr__panel--expired & {
opacity: 1;
pointer-events: auto;
}
}
.esr__retry:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
@media (max-width: 340px) {
.esr { gap: $sp-1; }
.esr__panel { padding: $sp-2 $sp-3; max-width: 170px; gap: 4px; }
.esr__code { width: 40px; height: 40px; font-size: 13px; }
.esr__msg { font-size: 10px; }
.esr__retry { padding: $sp-1 $sp-3; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.esr__panel { transform: none !important; }
}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);
}
.esr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.esr__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.esr__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;
}
.esr__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.esr__panel {
width: 100%;
max-width: 220px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform;
transition: background-color 220ms ease-out;
}
.esr__panel--expired {
background: rgba(255, 100, 100, 0.08);
}
.esr__code {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(20, 26, 38, 0.9);
color: #fff7e6;
font-weight: 800;
font-size: 20px;
transition: color 220ms ease-out;
}
.esr__panel--expired .esr__code {
color: #c2361a;
}
.esr__msg {
color: #fff7e6;
font-weight: 600;
font-size: 12px;
text-align: center;
}
.esr__retry {
border: 0;
border-radius: 999px;
padding: 4px 16px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 220ms ease-out;
}
.esr__panel--expired .esr__retry {
opacity: 1;
pointer-events: auto;
}
.esr__retry:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.esr {
gap: 4px;
}
.esr__panel {
padding: 8px 12px;
max-width: 170px;
gap: 4px;
}
.esr__code {
width: 40px;
height: 40px;
font-size: 13px;
}
.esr__msg {
font-size: 10px;
}
.esr__retry {
padding: 4px 12px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.esr__panel {
transform: none !important;
}
}(function () {
// EXPIRED_SHAKE_AMPLITUDE_PX — 변형 1: 10 → 16 으로 늘리면 만료 시 흔들림 폭이 커진다.
var EXPIRED_SHAKE_AMPLITUDE_PX = 10;
var LOOP_MS = 2000;
var EXPIRE_AT = 250;
var SHAKE_MS = 700;
var SHAKE_FREQ = 5;
// 시연 스케줄: 2초 루프 안에 만료→흔들림→자동 재시도→만료→흔들림 을 두 번 넣는다.
// RETRY_AT(자동 재시도)·EXPIRE_AT2(두 번째 만료)만 새로 추가 — 진폭·진동수·감쇠 함수는 그대로.
var RETRY_AT = 1050;
var EXPIRE_AT2 = 1300;
var root = document.getElementById('esrRoot');
var badge = document.getElementById('esrBadge');
var ctrl = document.getElementById('esrCtrl');
var panel = document.getElementById('esrPanel');
var codeEl = document.getElementById('esrCode');
var msgEl = document.getElementById('esrMsg');
var retryBtn = document.getElementById('esrRetry');
// 계산 한 겹: 진동수(SHAKE_FREQ)와 지수 감쇠를 곱한 감쇠 사인파로 흔들림을 만든다.
function shakeOffset(t) {
if (t < 0 || t > SHAKE_MS) return 0;
var p = t / SHAKE_MS;
return Math.sin(p * Math.PI * 2 * SHAKE_FREQ) * EXPIRED_SHAKE_AMPLITUDE_PX * Math.exp(-3.2 * p);
}
// 자동 재생에서 만료 구간 판정: 첫 만료(EXPIRE_AT~RETRY_AT) · 두 번째 만료(EXPIRE_AT2~루프 끝) 두 번
function autoExpired(elapsed) {
return (elapsed >= EXPIRE_AT && elapsed < RETRY_AT) || elapsed >= EXPIRE_AT2;
}
function autoExpireStart(elapsed) {
return elapsed < RETRY_AT ? EXPIRE_AT : EXPIRE_AT2;
}
var manualRetryAt = -1;
function render(elapsed) {
var expired = manualRetryAt < 0 && autoExpired(elapsed);
var t = manualRetryAt >= 0 ? elapsed - manualRetryAt : elapsed - autoExpireStart(elapsed);
var offset = expired ? shakeOffset(t) : 0;
panel.style.transform = offset ? 'translateX(' + offset.toFixed(2) + 'px)' : 'none';
// 계산 한 겹: 같은 감쇠 사인파(shakeOffset)의 크기를 그림자·배경 톤·QR 격자 흐림에 그대로 전파한다.
var normOffset = Math.min(1, Math.abs(offset) / EXPIRED_SHAKE_AMPLITUDE_PX);
panel.style.boxShadow = '0 14px 24px rgba(23, 20, 26, .32), 0 0 ' +
(normOffset * 20).toFixed(1) + 'px rgba(233, 64, 64, ' + (normOffset * 0.55).toFixed(2) + ')';
codeEl.style.filter = normOffset > 0.02 ? 'blur(' + (normOffset * 1.2).toFixed(2) + 'px)' : 'none';
if (expired) {
panel.classList.add('esr__panel--expired');
panel.style.backgroundColor = 'rgba(255, 100, 100, ' + (0.08 + normOffset * 0.10).toFixed(3) + ')';
codeEl.textContent = '×';
msgEl.textContent = '코드가 만료됐어요';
badge.textContent = '코드 만료';
} else {
panel.classList.remove('esr__panel--expired');
panel.style.backgroundColor = '';
codeEl.textContent = 'QR';
msgEl.textContent = '스캔해 주세요';
badge.textContent = '코드 유효';
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? EXPIRE_AT + SHAKE_MS : 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; manualRetryAt = -1; }
}
render(elapsed);
requestAnimationFrame(tick);
}
render(elapsed);
requestAnimationFrame(tick);
retryBtn.addEventListener('click', function () {
manualRetryAt = elapsed;
});
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
elapsed = 0;
manualRetryAt = -1;
playing = true;
ctrl.textContent = '일시정지';
}
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./ExpiredShakeRetry.module.scss";
export default function ExpiredShakeRetry() {
const rootRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const retryRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("코드 유효");
const [ctrlLabel, setCtrlLabel] = useState("일시정지");
const [code, setCode] = useState("QR");
const [msg, setMsg] = useState("스캔해 주세요");
const [expired, setExpired] = useState(false);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const ctrl = ctrlRef.current;
const retryBtn = retryRef.current;
if (!root || !panel || !ctrl || !retryBtn) return;
// EXPIRED_SHAKE_AMPLITUDE_PX — 변형 1: 10 → 16 으로 늘리면 만료 시 흔들림 폭이 커진다.
const EXPIRED_SHAKE_AMPLITUDE_PX = 10;
const LOOP_MS = 2000;
const EXPIRE_AT = 250;
const SHAKE_MS = 700;
const SHAKE_FREQ = 5;
// 시연 스케줄: 2초 루프 안에 만료→흔들림→자동 재시도→만료→흔들림 을 두 번 넣는다.
// RETRY_AT(자동 재시도)·EXPIRE_AT2(두 번째 만료)만 새로 추가 — 진폭·진동수·감쇠 함수는 그대로.
const RETRY_AT = 1050;
const EXPIRE_AT2 = 1300;
// 계산 한 겹: 진동수(SHAKE_FREQ)와 지수 감쇠를 곱한 감쇠 사인파로 흔들림을 만든다.
function shakeOffset(t: number) {
if (t < 0 || t > SHAKE_MS) return 0;
const p = t / SHAKE_MS;
return Math.sin(p * Math.PI * 2 * SHAKE_FREQ) * EXPIRED_SHAKE_AMPLITUDE_PX * Math.exp(-3.2 * p);
}
// 자동 재생에서 만료 구간 판정: 첫 만료(EXPIRE_AT~RETRY_AT) · 두 번째 만료(EXPIRE_AT2~루프 끝) 두 번
function autoExpired(elapsed: number) {
return (elapsed >= EXPIRE_AT && elapsed < RETRY_AT) || elapsed >= EXPIRE_AT2;
}
function autoExpireStart(elapsed: number) {
return elapsed < RETRY_AT ? EXPIRE_AT : EXPIRE_AT2;
}
let manualRetryAt = -1;
function render(elapsed: number) {
const isExpired = manualRetryAt < 0 && autoExpired(elapsed);
const t = manualRetryAt >= 0 ? elapsed - manualRetryAt : elapsed - autoExpireStart(elapsed);
const offset = isExpired ? shakeOffset(t) : 0;
panel!.style.transform = offset ? "translateX(" + offset.toFixed(2) + "px)" : "none";
// 계산 한 겹: 같은 감쇠 사인파(shakeOffset)의 크기를 그림자·배경 톤·QR 격자 흐림에 그대로 전파한다.
const normOffset = Math.min(1, Math.abs(offset) / EXPIRED_SHAKE_AMPLITUDE_PX);
panel!.style.boxShadow = "0 14px 24px rgba(23, 20, 26, .32), 0 0 " +
(normOffset * 20).toFixed(1) + "px rgba(233, 64, 64, " + (normOffset * 0.55).toFixed(2) + ")";
if (isExpired) {
setExpired(true);
setCode("×");
setMsg("코드가 만료됐어요");
setBadge("코드 만료");
} else {
setExpired(false);
setCode("QR");
setMsg("스캔해 주세요");
setBadge("코드 유효");
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? EXPIRE_AT + SHAKE_MS : 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; manualRetryAt = -1; }
}
render(elapsed);
raf = requestAnimationFrame(tick);
}
render(elapsed);
raf = requestAnimationFrame(tick);
function onRetry() {
manualRetryAt = elapsed;
}
function onClick() {
playing = !playing;
setCtrlLabel(playing ? "일시정지" : "재생");
}
function onKeydown(e: KeyboardEvent) {
if ((e.key === "r" || e.key === "R") && !reduce) {
elapsed = 0;
manualRetryAt = -1;
playing = true;
setCtrlLabel("일시정지");
}
}
retryBtn.addEventListener("click", onRetry);
ctrl.addEventListener("click", onClick);
ctrl.addEventListener("keydown", onKeydown);
return () => {
cancelAnimationFrame(raf);
retryBtn.removeEventListener("click", onRetry);
ctrl.removeEventListener("click", onClick);
ctrl.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.esr} is-demo`} ref={rootRef}>
<div className={styles.esr__badge}>{badge}</div>
<button className={styles.esr__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div
className={`${styles.esr__panel}${expired ? " " + styles["esr__panel--expired"] : ""}`}
ref={panelRef}
>
<div className={styles.esr__code}>{code}</div>
<div className={styles.esr__msg}>{msg}</div>
<button className={styles.esr__retry} type="button" ref={retryRef}>다시 받기</button>
</div>
</div>
</main>
);
}08 · 남은시간 값이 0에 닿으면 흔들리는 만료 QR 코드 — 측정 누적면적 32.133% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 계산 한 겹: 진동수(SHAKE_FREQ)와 지수 감쇠를 곱한 감쇠 사인파로 흔들림을 만든다.
function shakeOffset(t) {
if (t < 0 || t > SHAKE_MS) return 0;
var p = t / SHAKE_MS;
return Math.sin(p * Math.PI * 2 * SHAKE_FREQ) * EXPIRED_SHAKE_AMPLITUDE_PX * Math.exp(-3.2 * p);
}
// 자동 재생에서 만료 구간 판정: 첫 만료(EXPIRE_AT~RETRY_AT) · 두 번째 만료(EXPIRE_AT2~루프 끝) 두 번
function autoExpired(elapsed) {
return (elapsed >= EXPIRE_AT && elapsed < RETRY_AT) || elapsed >= EXPIRE_AT2;
}
function autoExpireStart(elapsed) {
return elapsed < RETRY_AT ? EXPIRE_AT : EXPIRE_AT2;
}
var manualRetryAt = -1;
function render(elapsed) {
var expired = manualRetryAt < 0 && autoExpired(elapsed);
var t = manualRetryAt >= 0 ? elapsed - manualRetryAt : elapsed - autoExpireStart(elapsed);
var offset = expired ? shakeOffset(t) : 0;
panel.style.transform = offset ? 'translateX(' + offset.toFixed(2) + 'px)' : 'none';
측정: 누적면적 32.1%, 강도 평균 65.1, 23/23 프레임에서 움직였다.
09인덱스 값이 거리로 바뀌어 코드가 넘어가는 캐러셀
경과 시간을 한 단계 지속시간으로 나눈 몫이 인덱스 값이 되고, 각 코드와 그 인덱스 사이 거리 값 하나가 원근 스케일·투명도·좌우 이동량을 한꺼번에 정한다. 멤버십 앱에서 쿠폰·포인트·결제 코드를 옆으로 넘기는 화면에 쓴다.

<main class="stage">
<div class="mcc is-demo" id="mccRoot">
<div class="mcc__badge" id="mccBadge">1 / 5</div>
<button class="mcc__ctrl" id="mccCtrl" type="button">일시정지</button>
<div class="mcc__canvas-wrap">
<canvas class="mcc__canvas" id="mccCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 현재 위치와 각 코드 사이 원형 거리로 원근 스케일·투명도·좌우 이동량을
// 한 번에 계산한다 — 가까울수록 크고 진하게, 멀수록 작고 흐리게.
@include stage($stage-ink);
.mcc {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.mcc__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;
}
.mcc__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;
}
.mcc__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.mcc__canvas-wrap {
width: 100%;
height: 220px;
border-radius: $r-card;
background: rgba(255, 255, 255, .04);
display: grid;
place-items: center;
overflow: hidden;
}
.mcc__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.mcc { gap: $sp-1; }
.mcc__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.mcc__canvas-wrap { height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.mcc__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);
}
.mcc {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.mcc__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;
}
.mcc__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;
}
.mcc__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.mcc__canvas-wrap {
width: 100%;
height: 220px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
display: grid;
place-items: center;
overflow: hidden;
}
.mcc__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.mcc {
gap: 4px;
}
.mcc__badge {
padding: 4px 8px;
font-size: 10px;
}
.mcc__canvas-wrap {
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.mcc__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// MULTI_CODE_CAROUSEL_STEP_MS — 변형 1: 650 → 1000 으로 늘리면 코드가 더 느긋하게 넘어간다.
var MULTI_CODE_CAROUSEL_STEP_MS = 650;
var COUNT = 5;
var LOOP_MS = MULTI_CODE_CAROUSEL_STEP_MS * COUNT;
var canvas = document.getElementById('mccCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var badge = document.getElementById('mccBadge');
var ctrl = document.getElementById('mccCtrl');
var root = document.getElementById('mccRoot');
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var grids = [];
for (var g = 0; g < COUNT; g += 1) {
var rand = seedRand(407090 + g * 17);
var dots = [];
for (var y = 0; y < 7; y += 1) {
for (var x = 0; x < 7; x += 1) {
if (rand() < 0.44) dots.push({ x: x, y: y });
}
}
grids.push(dots);
}
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 카드 간격·크기를 다시 잰다 —
// 카드 이동·원근 스케일이 스테이지를 채우도록(278px 캡 없이 반응형).
var dpr = 1, cw = 240, ch = 200, cy = 100, stepX = 74, boxBase = 60;
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);
cy = ch / 2;
stepX = cw * 0.31;
boxBase = Math.min(cw * 0.24, ch * 0.5);
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
ctx.clearRect(0, 0, cw, ch);
var raw = elapsed / MULTI_CODE_CAROUSEL_STEP_MS;
var index = Math.floor(raw) % COUNT;
var localT = raw - Math.floor(raw);
var eased = easeInOut(localT);
var pos = index + eased;
for (var i = 0; i < COUNT; i += 1) {
// 계산 한 겹: 현재 위치와의 거리 → 원근 스케일·투명도·좌우 이동량을 함께 구한다.
var diff = i - pos;
while (diff > COUNT / 2) diff -= COUNT;
while (diff < -COUNT / 2) diff += COUNT;
var dist = Math.abs(diff);
if (dist > 1.8) continue;
var scale = Math.max(0.35, 1 - dist * 0.42);
var alpha = Math.max(0, 1 - dist * 0.55);
var cx = cw / 2 + diff * stepX;
var box = boxBase * scale;
ctx.globalAlpha = alpha;
ctx.fillStyle = 'rgba(20,26,38,.92)';
ctx.fillRect(cx - box / 2, cy - box / 2, box, box);
var cell = box / 7;
ctx.fillStyle = 'rgb(255,247,230)';
grids[i].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);
});
}
ctx.globalAlpha = 1;
badge.textContent = (index + 1) + ' / ' + COUNT;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? 0 : 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 "./MultiCodeCarousel.module.scss";
export default function MultiCodeCarousel() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("1 / 5");
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;
// MULTI_CODE_CAROUSEL_STEP_MS — 변형 1: 650 → 1000 으로 늘리면 코드가 더 느긋하게 넘어간다.
const MULTI_CODE_CAROUSEL_STEP_MS = 650;
const COUNT = 5;
const LOOP_MS = MULTI_CODE_CAROUSEL_STEP_MS * COUNT;
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 grids: { x: number; y: number }[][] = [];
for (let g = 0; g < COUNT; g += 1) {
const rand = seedRand(407090 + g * 17);
const dots: { x: number; y: number }[] = [];
for (let y = 0; y < 7; y += 1) {
for (let x = 0; x < 7; x += 1) {
if (rand() < 0.44) dots.push({ x, y });
}
}
grids.push(dots);
}
function easeInOut(t: number) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 카드 간격·크기를 다시 잰다 —
// 카드 이동·원근 스케일이 스테이지를 채우도록(278px 캡 없이 반응형).
let dpr = 1, cw = 240, ch = 200, cy = 100, stepX = 74, boxBase = 60;
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);
cy = ch / 2;
stepX = cw * 0.31;
boxBase = Math.min(cw * 0.24, ch * 0.5);
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
ctx!.clearRect(0, 0, cw, ch);
const raw = elapsed / MULTI_CODE_CAROUSEL_STEP_MS;
const index = Math.floor(raw) % COUNT;
const localT = raw - Math.floor(raw);
const eased = easeInOut(localT);
const pos = index + eased;
for (let i = 0; i < COUNT; i += 1) {
// 계산 한 겹: 현재 위치와의 거리 → 원근 스케일·투명도·좌우 이동량을 함께 구한다.
let diff = i - pos;
while (diff > COUNT / 2) diff -= COUNT;
while (diff < -COUNT / 2) diff += COUNT;
const dist = Math.abs(diff);
if (dist > 1.8) continue;
const scale = Math.max(0.35, 1 - dist * 0.42);
const alpha = Math.max(0, 1 - dist * 0.55);
const cx = cw / 2 + diff * stepX;
const box = boxBase * scale;
ctx!.globalAlpha = alpha;
ctx!.fillStyle = "rgba(20,26,38,.92)";
ctx!.fillRect(cx - box / 2, cy - box / 2, box, box);
const cell = box / 7;
ctx!.fillStyle = "rgb(255,247,230)";
grids[i].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);
});
}
ctx!.globalAlpha = 1;
setBadge((index + 1) + " / " + COUNT);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = 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.mcc} is-demo`} ref={rootRef}>
<div className={styles.mcc__badge}>{badge}</div>
<button className={styles.mcc__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["mcc__canvas-wrap"]}>
<canvas className={styles.mcc__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}09 · 여러 코드가 인덱스 값을 따라 넘어가는 QR 캐러셀 — 측정 누적면적 18.378% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(elapsed) {
lastElapsed = elapsed;
ctx.clearRect(0, 0, cw, ch);
var raw = elapsed / MULTI_CODE_CAROUSEL_STEP_MS;
var index = Math.floor(raw) % COUNT;
var localT = raw - Math.floor(raw);
var eased = easeInOut(localT);
var pos = index + eased;
for (var i = 0; i < COUNT; i += 1) {
// 계산 한 겹: 현재 위치와의 거리 → 원근 스케일·투명도·좌우 이동량을 함께 구한다.
var diff = i - pos;
while (diff > COUNT / 2) diff -= COUNT;
while (diff < -COUNT / 2) diff += COUNT;
var dist = Math.abs(diff);
if (dist > 1.8) continue;
var scale = Math.max(0.35, 1 - dist * 0.42);
var alpha = Math.max(0, 1 - dist * 0.55);
var cx = cw / 2 + diff * stepX;
var box = boxBase * scale;
ctx.globalAlpha = alpha;
ctx.fillStyle = 'rgba(20,26,38,.92)';
ctx.fillRect(cx - box / 2, cy - box / 2, box, box);
측정: 누적면적 18.4%, 강도 평균 118.8, 23/23 프레임에서 움직였다.
css 로딩바 애니메이션 아홉 개가 남은시간 값 하나로 갈라지는 세 갈래
아홉 개 전부 같은 축 하나에서 출발한다 — 생성 진행 값이거나 남은시간 비율 값이다. 이 값이 어디에 대입되느냐로 장면이 갈린다. 01·06은 지연·진폭처럼 모듈 하나하나의 등장 시점에 대입하고, 02·03·04·08은 거리·비율·각도로 바꿔 링이나 코너의 크기·색·흔들림에 대입하며, 07·09는 자리·인덱스 값으로 전환 그 자체(자릿수가 구르는 순서, 코드가 넘어가는 순서)를 대입한다. 05는 예외로 판정 시점(인식 순간) 값 하나를 스프링 곡선에 넣어 튕긴다. 캔버스 2D API로 매 프레임 좌표·반경·각도를 직접 계산해 그리므로, 속성을 트윈만 하는 것과 달리 값이 바뀌는 그 순간 화면 전체의 밝기·두께·위치가 같이 갱신된다 — MDN Canvas API 문서에 이 렌더링 방식의 기본기가 정리돼 있다.
9개 패턴이 화면을 실제로 얼마나 바꿨는지 — 프레임별 실측
렌더 화면을 24장 찍어 바로 앞 장과 비교한 수치다. 움직인프레임 칸은 그 24장을 잇는 23번의 비교 가운데 실제로 화면이 달라진 횟수를 센다.
| 항목 | 누적면적% | 강도평균 | 움직인프레임 |
|---|---|---|---|
| 01 모듈 리빌 | 17.9 | 83.8 | 9/23 |
| 02 코너 프레임 | 28.2 | 62.6 | 23/23 |
| 03 펄스 링 만료 | 40.2 | 46.9 | 23/23 |
| 04 만료 카운트다운 링 | 61.7 | 81.0 | 23/23 |
| 05 스캔 성공 체크 | 6.9 | 119.7 | 14/23 |
| 06 생성 노이즈 정착 | 11.0 | 44.7 | 23/23 |
| 07 금액 롤 스왑 | 11.5 | 74.0 | 20/23 |
| 08 만료 흔들림 재시도 | 32.1 | 65.1 | 23/23 |
| 09 멀티 코드 캐러셀 | 18.4 | 118.8 | 23/23 |
04가 61.7%로 가장 넓게 움직인 이유는 전체 화면(배경·링·외곽 글로우)이 urgency 값 하나로 함께 바뀌기 때문이고, 05가 6.9%로 가장 좁은 이유는 체크 배지 하나만 짧게 튕기는 동작이라서다 — 항목 성격이 다르면 면적이 좁은 쪽이 오히려 정상이라 억지로 면적을 키우지 않았다.
결제·쿠폰·티켓 화면에 이 아홉 개를 놓는 자리
결제 QR 코드 생성·검증 흐름에서는 01(생성) → 04(유효시간) → 08(만료) 순서로 이어 붙이면 같은 화면 하나가 생성부터 재발급까지 상태를 전부 보여준다. 매장 결제 스캔 대기 화면에는 02를, 인식 완료에는 05를 붙이는 식으로 진행 상태마다 화면을 새로 그리지 않고 같은 캔버스 위에서 값만 갈아 끼울 수 있다. 매장 대기 화면에 흔한 css 로딩 스피너 하나로 뭉뚱그리지 않고 02처럼 상태를 보여주는 코너 표시를 놓으면 대기 이유가 더 분명해지고, 정적인 로딩바 디자인 대신 QR 자체가 진행·만료 상태를 보여주면 사용자가 곁의 문구를 따로 읽지 않아도 된다. 09는 멤버십 앱처럼 코드 여러 개를 한 화면에서 오가야 하는 곳에 붙인다.
시연 배속을 지우지 않으면 깨지는 함정
04의 가상 시계 배속을 실서비스에 그대로 옮기면
04의 QR_EXPIRE_DURATION_MS 는 30000(30초)으로 그대로 두지만, 데모는 DEMO_ACCEL_FACTOR(15배) 를 곱한 가상 시계로 짧은 측정 창 안에 전체 카운트다운을 압축해서 보여준다. 이 배속은 root.classList.contains('is-demo') 로 감지한 시연 전용 클래스에만 걸리므로, 코드를 그대로 복사하되 is-demo 클래스를 붙인 채로 실서비스에 올리면 30초짜리 만료가 순식간에 끝나 버린다. 값을 실사용에 옮길 때는 이 클래스를 빼서 배속 자체를 제거해야 한다.
저사양 기기에서 캔버스 프레임이 밀리면
아홉 개 전부 매 프레임 clearRect 로 캔버스를 비우고 다시 그리므로, 저사양 기기에서 프레임이 밀리면 04의 색 보간이나 08의 흔들림처럼 값이 크게 바뀌는 항목일수록 뚝뚝 끊겨 보인다. requestAnimationFrame 지원이 불안정한 낡은 환경에서는 재생을 직접 멈춰 마지막 프레임만 정적으로 보여주는 편이 낫다.
직접 코드를 열어 이 값이 어디까지 퍼지는지 대조해 보고 싶다면 wkt849wy으로 아홉 개 데모의 HTML·SCSS·JS 원본을 그대로 받아 확인하면 된다.
값 하나만 바꿔보는 변형 세 가지
01의 QR_MODULE_REVEAL_STAGGER_MS 는 모듈이 드러나는 행 간 지연 값이다 — 18ms일 때는 모듈이 거의 한꺼번에 쏟아지듯 채워지지만, 36ms로 늘리면 한 칸씩 눈에 보이게 천천히 채워진다. 04의 QR_EXPIRE_DURATION_MS 는 만료까지 걸리는 지속시간 값으로, 30000ms에서 15000ms로 줄이면 카운트다운 링이 훨씬 빠르게 깎여 나가 다급한 인상을 준다. 06의 QR_NOISE_SETTLE_AMPLITUDE_PX 는 생성 중 흔들림의 최대 진폭 값이며, 6px에서 2px로 줄이면 흔들림이 약해져 처음부터 거의 안정된 것처럼 보인다.
| 항목 | 상수 | 전 → 후 |
|---|---|---|
| 01 | QR_MODULE_REVEAL_STAGGER_MS | 18ms → 36ms |
| 04 | QR_EXPIRE_DURATION_MS | 30000ms → 15000ms |
| 06 | QR_NOISE_SETTLE_AMPLITUDE_PX | 6px → 2px |
접근성 — reduced-motion 값 하나로 아홉 개가 멈추는 법
아홉 개 전부 window.matchMedia('(prefers-reduced-motion: reduce)').matches 값을 시작할 때 한 번 읽어, 참이면 requestAnimationFrame 루프 자체를 돌리지 않고 각 데모를 완결된 정지 프레임 하나로 고정한다. 01은 전체 스윕이 끝난 상태, 04는 남은시간이 다 닳아 정적으로 붉게 멈춘 상태, 09는 첫 코드에 고정된 상태처럼 항목마다 그 장면이 "다 끝났을 때"의 모습으로 멈춘다. reduced-motion 이 켜진 화면에서는 재생 버튼 대신 정지된 QR과 상태 문구만 남고, 캔버스 배경 톤을 살짝 낮춰 정적인 화면임을 알리는 항목도 있다. MDN prefers-reduced-motion 문서에 이 미디어 쿼리의 기준이 정리돼 있다.
395·399·403편과 무엇이 다른가?
캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로도 캔버스 2D 로 직접 그리지만 물리 시뮬레이션(중력·충돌) 값이 축이고, 이 편은 시간·거리·인덱스 같은 상태값이 축이라 물리 연산이 없다. css 로딩바 애니메이션 9가지 — 3D 계산형 로더는 정점·면 값을 3D 좌표로 투영하는 계산이 중심이지만, 이 편은 평면 캔버스 위에서 반경·각도·거리 같은 2D 값만 계산한다. AI 로딩 애니메이션 9가지 — 값 하나로 도는 처리중 표시는 대기 시간이라는 불확실한 값을 다루는 반면, 이 편의 04·08은 정해진 만료 시각까지 정확히 줄어드는 값을 다룬다는 점이 다르다.
qr 코드 만료 카운트다운을 적용하기 전에 자주 나오는 질문
이 QR 격자가 실제 QR 코드로 스캔되나요?
아니요. 격자는 고정 시드로 찍은 의사난수 장식 패턴이고 실제 데이터를 인코딩하지 않는다. 화면에 QR 모양의 움직임을 보여주는 목업이므로 카메라로 스캔해도 읽히지 않는다.
04의 30초 만료가 데모에서는 왜 훨씬 빨리 끝나나요?
데모는 30초 만료를 짧은 측정 창 안에서 보여주려고 DEMO_ACCEL_FACTOR(15배) 가상 시계 배속을 곱해서 재생한다. QR_EXPIRE_DURATION_MS 상수 자체는 30000(30초) 그대로이고, 실서비스에서는 이 배속을 곱하는 is-demo 클래스를 빼면 원래 속도로 돌아간다.
로딩 스피너 디자인 대신 이 아홉 개를 그대로 붙여도 되나요?
그대로 붙여도 된다. 아홉 개 전부 requestAnimationFrame 과 캔버스 2D API 만으로 동작해 외부 라이브러리가 필요 없고, zip 안 HTML·SCSS·JS 코드를 그대로 옮기면 된다.









