02QR Code Whose Corner Frame Blinks While Waiting to Be Read
The brightness value of the four corner marks rises and falls in turn, signaling the idle state while it waits for a camera to read it.
Best forIn-store payment scan idle screen
SCF_CORNER_GLOW_RANGE_PXcornerGlowseedRand

<main class="stage">
<div class="scf is-demo" id="scfRoot">
<div class="scf__badge" id="scfBadge">Scanning 0%</div>
<button class="scf__ctrl" id="scfCtrl" type="button">Pause</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 = 'Scanning ' + 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 ? 'Pause' : 'Play';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
elapsed = 0;
playing = true;
ctrl.textContent = 'Pause';
}
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./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 Code Whose Corner Frame Blinks While Waiting to Be Read — Measured area 28.194% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · QR Code Loading Animation — 9 CSS Expiry Patterns
The post this demo lives in
QR Code Loading Animation — 9 CSS Expiry PatternsNot a scan failure, not sitting too long — one time left ratio for qr code loading animation must run out: item 3 shrink See all demos in this post
If you like this effect - similar demos
Loaders08 Button Whose Label Text Cycles Through Steps While LoadingLoading Button UI — 9 States, Spinner to Success
Loaders03 Donut Chart Loading Whose Waiting Ring Morphs Into Real SegmentsChart Loading Animation — 9 Computed Patterns
Loaders01 Halo Ring AI Loader Whose Hue Value Turns Toward DoneAI Loading Animation: 9 Patterns by One Value