02코너 프레임이 깜빡이며 인식을 기다리는 QR 코드
코드 테두리 네 모서리의 밝기 값이 번갈아 오르내리며 카메라 인식을 기다리는 대기 상태를 보여준다.
쓰임매장 결제 스캔 대기 화면
SCF_CORNER_GLOW_RANGE_PXcornerGlowseedRand

<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 포함 · qr 코드 만료 카운트다운 css 애니메이션 9가지
이 데모가 속한 글
qr 코드 만료 카운트다운 css 애니메이션 9가지qr 코드 만료 표시는 값 하나로 아홉 장면을 만든다. 스캔 실패 때문도 아니고 그냥 오래 떠 있어서도 아니다 — qr 코드 만료까지 남은시간 비율 값 하나가 사라져야 링이 좁아지고, 각도가 깎이고, 끝에 닿으면 흔 이 글의 데모 전부 보기


