09Hero Scene Arranging Several Cards on One Screen
Item 01's pixel calc, item 03's flake physics, and item 07's confetti all run together across several cards laid out on one screen.
Best forTop-of-landing-page promo banner
makeCardHERO_REVEAL_THRESHOLD_PCTstepParticles

<main class="stage">
<div class="scv is-demo" id="scv">
<p class="scv__label">Scratch card 3-pack</p>
<div class="scv__grid" id="grid">
<div class="scv__card" data-idx="0">
<div class="scv__zone" id="zone0">
<div class="scv__prize"><p class="scv__prizeTitle">10%</p><p class="scv__prizeSub">Discount coupon</p></div>
<canvas class="scv__foil" id="foil0" width="150" height="120" aria-hidden="true"></canvas>
<canvas class="scv__fx" id="fx0" width="150" height="120" aria-hidden="true"></canvas>
</div>
</div>
<div class="scv__card" data-idx="1">
<div class="scv__zone" id="zone1">
<div class="scv__prize"><p class="scv__prizeTitle">Free shipping</p><p class="scv__prizeSub">Coupon</p></div>
<canvas class="scv__foil" id="foil1" width="150" height="120" aria-hidden="true"></canvas>
<canvas class="scv__fx" id="fx1" width="150" height="120" aria-hidden="true"></canvas>
</div>
</div>
<div class="scv__card" data-idx="2">
<div class="scv__zone" id="zone2">
<div class="scv__prize"><p class="scv__prizeTitle">Reward points</p><p class="scv__prizeSub">₩2,000</p></div>
<canvas class="scv__foil" id="foil2" width="150" height="120" aria-hidden="true"></canvas>
<canvas class="scv__fx" id="fx2" width="150" height="120" aria-hidden="true"></canvas>
</div>
</div>
</div>
<button type="button" class="scv__reveal" id="revealBtn">Reveal all instantly</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 09 — 01(픽셀 임계값 자동 벗김)+03(조각 중력)+07(색종이 폭발) 로직을
// 카드 3장에 동시에 적용한다. HERO_ 접두 상수로 01/03/07 의 변형 플래그 상수와 이름이
// 겹치지 않게 한다.
@include stage($stage-ink);
.scv { display: flex; flex-direction: column; align-items: center; width: 100%; gap: $sp-2; }
.scv__label { margin: 0; font-weight: 800; font-size: 12px; letter-spacing: .04em; color: $subject-cream; }
.scv__grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2;
width: min(100%, 420px);
}
.scv__card {
background: $subject-blue-deep; border-radius: $r-card; box-shadow: $shadow-raised; padding: $sp-2;
}
.scv__zone {
position: relative; border-radius: $r-control; overflow: hidden;
aspect-ratio: 150 / 210; background: $stage-paper;
}
.scv__prize {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: $sp-1; text-align: center; padding: $sp-1; z-index: 0;
}
.scv__prizeTitle { margin: 0; font-weight: 800; font-size: clamp(13px, 4vw, 16px); color: $subject-blue-deep; }
.scv__prizeSub { margin: 0; font-weight: 700; font-size: 9px; color: rgba(23, 20, 26, .68); }
.scv__foil { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; z-index: 1; }
.scv__fx { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 2; }
.scv__zone.is-open .scv__foil { visibility: hidden; }
.scv__reveal {
border: 0; cursor: pointer; background: $subject-cream; color: $subject-blue-deep;
border-radius: $r-pill; font-family: inherit; font-weight: 800; font-size: 11px; padding: $sp-1 $sp-3; line-height: 1.3;
}
.scv__reveal:focus-visible { outline: 3px solid $stage-paper; outline-offset: 2px; }
.scv__live { margin: 0; min-height: 1.1em; font-weight: 700; font-size: 10px; color: $subject-cream; }
@media (max-width: 340px) {
.scv__grid { grid-template-columns: 1fr 1fr; }
}
@media (max-height: 220px) {
.stage { padding: $sp-2; }
.scv { gap: $sp-1; }
.scv__grid { grid-template-columns: repeat(3, 1fr); gap: $sp-1; }
.scv__card { padding: $sp-1; }
.scv__zone { aspect-ratio: 150 / 64; }
.scv__live { min-height: .9em; }
}
@media (prefers-reduced-motion: reduce) {
.scv { cursor: default; }
}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);
}
.scv {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: 8px;
}
.scv__label {
margin: 0;
font-weight: 800;
font-size: 12px;
letter-spacing: 0.04em;
color: #fff7e6;
}
.scv__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
width: min(100%, 420px);
}
.scv__card {
background: #0a4fe8;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
}
.scv__zone {
position: relative;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 150/210;
background: #ffffff;
}
.scv__prize {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
padding: 4px;
z-index: 0;
}
.scv__prizeTitle {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 4vw, 16px);
color: #0a4fe8;
}
.scv__prizeSub {
margin: 0;
font-weight: 700;
font-size: 9px;
color: rgba(23, 20, 26, 0.68);
}
.scv__foil {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: none;
z-index: 1;
}
.scv__fx {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
}
.scv__zone.is-open .scv__foil {
visibility: hidden;
}
.scv__reveal {
border: 0;
cursor: pointer;
background: #fff7e6;
color: #0a4fe8;
border-radius: 999px;
font-family: inherit;
font-weight: 800;
font-size: 11px;
padding: 4px 12px;
line-height: 1.3;
}
.scv__reveal:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 2px;
}
.scv__live {
margin: 0;
min-height: 1.1em;
font-weight: 700;
font-size: 10px;
color: #fff7e6;
}
@media (max-width: 340px) {
.scv__grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-height: 220px) {
.stage {
padding: 8px;
}
.scv {
gap: 4px;
}
.scv__grid {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.scv__card {
padding: 4px;
}
.scv__zone {
aspect-ratio: 150/64;
}
.scv__live {
min-height: 0.9em;
}
}
@media (prefers-reduced-motion: reduce) {
.scv {
cursor: default;
}
}(function () {
// 기법: 01 의 픽셀 임계값 자동 벗김, 03 의 조각 중력, 07 의 색종이 폭발을 카드 3장에
// 각각 적용한다. 이름 충돌을 피하려 HERO_ 접두 상수를 새로 둔다(01/03/07 의 상수와는 별개 값).
var root = document.getElementById('scv');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var HERO_REVEAL_THRESHOLD_PCT = 55;
var HERO_FLAKE_GRAVITY_PX_S2 = 500;
var HERO_CONFETTI_COUNT = 16;
var SCRATCH_BRUSH_R = 12;
var FLAKE_LIFE_MS = 800;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var PRIZE_TEXT = ['The 10% off coupon is revealed', 'The free shipping coupon is revealed', 'The ₩2,000 rewards coupon is revealed'];
var COLORS = ['#0a4fe8', '#fff7e6', '#ff4d1f', '#ffd23f'];
function makeCard(idx) {
var foil = document.getElementById('foil' + idx);
var fxCanvas = document.getElementById('fx' + idx);
var zone = document.getElementById('zone' + idx);
var ctx = foil.getContext('2d');
var fctx = fxCanvas.getContext('2d');
var FW = foil.width, FH = foil.height;
var foilRand = seedRand(424091 + idx * 7);
var fxRand = seedRand(424092 + idx * 7);
var particles = [];
var opened = false, burst = false;
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, FW, FH);
var grad = ctx.createLinearGradient(0, 0, FW, FH);
var stops = idx === 0 ? ['#5b6cff', '#8f9bff'] : idx === 1 ? ['#e8c344', '#f6dd7c'] : ['#ff8a5c', '#ffb488'];
grad.addColorStop(0, stops[0]);
grad.addColorStop(1, stops[1]);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FW, FH);
ctx.strokeStyle = 'rgba(23,20,26,.16)';
ctx.lineWidth = 1;
for (var i = 0; i < 12; i += 1) {
var x = foilRand() * FW, y = foilRand() * FH;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 16 - 8), y + (foilRand() * 6 - 3));
ctx.stroke();
}
}
function spawnFlake(x, y) {
if (reduce) return;
particles.push({
kind: 'flake', x: x, y: y,
vx: (fxRand() - .5) * 60, vy: -40 - fxRand() * 30,
rot: fxRand() * Math.PI * 2, angVel: (fxRand() - .5) * 8,
born: 0, size: 3 + fxRand() * 2, color: 'rgba(255,255,255,.85)', bounces: 99
});
}
function burstConfetti() {
for (var i = 0; i < HERO_CONFETTI_COUNT; i += 1) {
particles.push({
kind: 'confetti', x: FW / 2, y: FH * 0.3,
vx: (fxRand() - .5) * 160, vy: -140 - fxRand() * 100,
rot: fxRand() * Math.PI * 2, angVel: (fxRand() - .5) * 7,
born: 0, size: 4 + fxRand() * 2, color: COLORS[i % COLORS.length], bounces: 0
});
}
}
function stepParticles(dtMs) {
var dtS = dtMs / 1000;
var alive = [];
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
p.vy += HERO_FLAKE_GRAVITY_PX_S2 * dtS;
p.x += p.vx * dtS;
p.y += p.vy * dtS;
p.rot += p.angVel * dtS;
p.born += dtMs;
if (p.kind === 'confetti' && p.y > FH - 4 && p.vy > 0 && p.bounces < 2) {
p.y = FH - 4; p.vy *= -0.4; p.bounces += 1;
}
var keepFlake = p.kind === 'flake' && p.born < FLAKE_LIFE_MS && p.y < FH + 8;
var keepConfetti = p.kind === 'confetti' && p.born < 1300;
if (keepFlake || keepConfetti) alive.push(p);
}
particles = alive;
}
function drawParticles() {
fctx.clearRect(0, 0, FW, FH);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var fade = p.kind === 'flake' ? Math.max(0, 1 - p.born / FLAKE_LIFE_MS) : 1;
fctx.save();
fctx.translate(p.x, p.y);
fctx.rotate(p.rot);
fctx.globalAlpha = fade;
fctx.fillStyle = p.color;
fctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
fctx.restore();
}
}
function clearedRatio() {
var data = ctx.getImageData(0, 0, FW, FH).data;
var clear = 0, total = data.length / 4;
for (var i = 3; i < data.length; i += 4) { if (data[i] < 40) clear += 1; }
return clear / total;
}
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
spawnFlake(x, y);
}
function openCard() {
if (opened) return;
opened = true;
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(0, 0, FW, FH);
zone.classList.add('is-open');
if (!burst) { burst = true; burstConfetti(); }
live.textContent = PRIZE_TEXT[idx];
}
function checkThreshold() {
var ratio = clearedRatio();
if (ratio * 100 >= HERO_REVEAL_THRESHOLD_PCT) openCard();
}
function pos(e) {
var r = foil.getBoundingClientRect();
return { x: (e.clientX - r.left) * (FW / r.width), y: (e.clientY - r.top) * (FH / r.height) };
}
paintFoil();
var dragging = false;
zone.addEventListener('pointerdown', function (e) {
if (opened) return;
root.classList.remove('is-demo');
dragging = true;
var p = pos(e);
scratchAt(p.x, p.y);
checkThreshold();
if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (!dragging || opened) return;
var p = pos(e);
scratchAt(p.x, p.y);
checkThreshold();
});
window.addEventListener('pointerup', function () { dragging = false; });
var demoRand = seedRand(424093 + idx * 7);
var demoPath = [];
var pointCount = 14;
for (var i = 0; i < pointCount; i += 1) {
demoPath.push({
t: 220 + idx * 260 + i * 92,
x: (FW * 0.14) + (i % 4) * (FW * 0.22) + (demoRand() - .5) * 5,
y: (FH * 0.2) + Math.floor(i / 4) * (FH * 0.28) + (demoRand() - .5) * 5
});
}
var demoStep = 0;
return {
reset: function () {
opened = false; burst = false;
zone.classList.remove('is-open');
paintFoil();
particles = [];
demoStep = 0;
},
runDemo: function (tMod) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
var p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
checkThreshold();
}
},
step: function (dt) { if (!reduce) stepParticles(dt); drawParticles(); },
revealAll: function () { scratchAt(FW / 2, FH / 2); openCard(); }
};
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var cards = [makeCard(0), makeCard(1), makeCard(2)];
revealBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
for (var i = 0; i < cards.length; i += 1) cards[i].revealAll();
});
var DEMO_LOOP_MS = 4600;
if (reduce) root.classList.remove('is-demo');
var elapsed = 0, lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce && root.classList.contains('is-demo')) {
elapsed += dt;
var tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) { for (var i = 0; i < cards.length; i += 1) cards[i].reset(); }
for (var j = 0; j < cards.length; j += 1) cards[j].runDemo(tMod);
}
for (var k = 0; k < cards.length; k += 1) cards[k].step(dt);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealHeroMultiCardScene.module.scss";
type Particle = {
kind: "flake" | "confetti"; x: number; y: number; vx: number; vy: number;
rot: number; angVel: number; born: number; size: number; color: string; bounces: number;
};
const PRIZE_TEXT = ["10% 할인 쿠폰이 열렸어요", "무료배송 쿠폰이 열렸어요", "적립금 2,000원 쿠폰이 열렸어요"];
const COLORS = ["#0a4fe8", "#fff7e6", "#ff4d1f", "#ffd23f"];
export default function ScratchCardRevealHeroMultiCardScene() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
const foilRefs = useRef<(HTMLCanvasElement | null)[]>([null, null, null]);
const fxRefs = useRef<(HTMLCanvasElement | null)[]>([null, null, null]);
const revealBtnRef = useRef<HTMLButtonElement>(null);
const liveRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const revealBtn = revealBtnRef.current;
const live = liveRef.current;
if (!root || !revealBtn || !live) return;
if (!zoneRefs.current || !foilRefs.current || !fxRefs.current) return;
if (zoneRefs.current.some((z) => !z) || foilRefs.current.some((f) => !f) || fxRefs.current.some((f) => !f)) return;
const zones = zoneRefs.current as HTMLDivElement[];
const foils = foilRefs.current as HTMLCanvasElement[];
const fxCanvases = fxRefs.current as HTMLCanvasElement[];
const HERO_REVEAL_THRESHOLD_PCT = 55;
const HERO_FLAKE_GRAVITY_PX_S2 = 500;
const HERO_CONFETTI_COUNT = 16;
const SCRATCH_BRUSH_R = 12;
const FLAKE_LIFE_MS = 800;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function makeCard(idx: number) {
const foil = foils[idx]!;
const fxCanvas = fxCanvases[idx]!;
const zone = zones[idx]!;
const ctx = foil.getContext("2d")!;
const fctx = fxCanvas.getContext("2d")!;
const FW = foil.width, FH = foil.height;
const foilRand = seedRand(424091 + idx * 7);
const fxRand = seedRand(424092 + idx * 7);
let particles: Particle[] = [];
let opened = false, burst = false;
function paintFoil() {
ctx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, FW, FH);
const grad = ctx.createLinearGradient(0, 0, FW, FH);
const stops = idx === 0 ? ["#5b6cff", "#8f9bff"] : idx === 1 ? ["#e8c344", "#f6dd7c"] : ["#ff8a5c", "#ffb488"];
grad.addColorStop(0, stops[0]);
grad.addColorStop(1, stops[1]);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FW, FH);
ctx.strokeStyle = "rgba(23,20,26,.16)";
ctx.lineWidth = 1;
for (let i = 0; i < 12; i += 1) {
const x = foilRand() * FW, y = foilRand() * FH;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 16 - 8), y + (foilRand() * 6 - 3));
ctx.stroke();
}
}
function spawnFlake(x: number, y: number) {
if (reduce) return;
particles.push({
kind: "flake", x, y,
vx: (fxRand() - .5) * 60, vy: -40 - fxRand() * 30,
rot: fxRand() * Math.PI * 2, angVel: (fxRand() - .5) * 8,
born: 0, size: 3 + fxRand() * 2, color: "rgba(255,255,255,.85)", bounces: 99
});
}
function burstConfetti() {
for (let i = 0; i < HERO_CONFETTI_COUNT; i += 1) {
particles.push({
kind: "confetti", x: FW / 2, y: FH * 0.3,
vx: (fxRand() - .5) * 160, vy: -140 - fxRand() * 100,
rot: fxRand() * Math.PI * 2, angVel: (fxRand() - .5) * 7,
born: 0, size: 4 + fxRand() * 2, color: COLORS[i % COLORS.length], bounces: 0
});
}
}
function stepParticles(dtMs: number) {
const dtS = dtMs / 1000;
const alive: Particle[] = [];
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
p.vy += HERO_FLAKE_GRAVITY_PX_S2 * dtS;
p.x += p.vx * dtS;
p.y += p.vy * dtS;
p.rot += p.angVel * dtS;
p.born += dtMs;
if (p.kind === "confetti" && p.y > FH - 4 && p.vy > 0 && p.bounces < 2) {
p.y = FH - 4; p.vy *= -0.4; p.bounces += 1;
}
const keepFlake = p.kind === "flake" && p.born < FLAKE_LIFE_MS && p.y < FH + 8;
const keepConfetti = p.kind === "confetti" && p.born < 1300;
if (keepFlake || keepConfetti) alive.push(p);
}
particles = alive;
}
function drawParticles() {
fctx.clearRect(0, 0, FW, FH);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const fade = p.kind === "flake" ? Math.max(0, 1 - p.born / FLAKE_LIFE_MS) : 1;
fctx.save();
fctx.translate(p.x, p.y);
fctx.rotate(p.rot);
fctx.globalAlpha = fade;
fctx.fillStyle = p.color;
fctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
fctx.restore();
}
}
function clearedRatio() {
const data = ctx.getImageData(0, 0, FW, FH).data;
let clear = 0;
const total = data.length / 4;
for (let i = 3; i < data.length; i += 4) { if (data[i] < 40) clear += 1; }
return clear / total;
}
function scratchAt(x: number, y: number) {
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
spawnFlake(x, y);
}
function openCard() {
if (opened) return;
opened = true;
ctx.globalCompositeOperation = "destination-out";
ctx.fillRect(0, 0, FW, FH);
zone.classList.add("is-open");
if (!burst) { burst = true; burstConfetti(); }
live!.textContent = PRIZE_TEXT[idx];
}
function checkThreshold() {
const ratio = clearedRatio();
if (ratio * 100 >= HERO_REVEAL_THRESHOLD_PCT) openCard();
}
function pos(e: PointerEvent) {
const r = foil.getBoundingClientRect();
return { x: (e.clientX - r.left) * (FW / r.width), y: (e.clientY - r.top) * (FH / r.height) };
}
paintFoil();
let dragging = false;
function onDown(e: PointerEvent) {
if (opened) return;
root!.classList.remove("is-demo");
dragging = true;
const p = pos(e);
scratchAt(p.x, p.y);
checkThreshold();
zone.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging || opened) return;
const p = pos(e);
scratchAt(p.x, p.y);
checkThreshold();
}
function onUp() { dragging = false; }
zone.addEventListener("pointerdown", onDown);
zone.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
const demoRand = seedRand(424093 + idx * 7);
type DemoPt = { t: number; x: number; y: number };
const demoPath: DemoPt[] = [];
const pointCount = 14;
for (let i = 0; i < pointCount; i += 1) {
demoPath.push({
t: 220 + idx * 260 + i * 92,
x: (FW * 0.14) + (i % 4) * (FW * 0.22) + (demoRand() - .5) * 5,
y: (FH * 0.2) + Math.floor(i / 4) * (FH * 0.28) + (demoRand() - .5) * 5
});
}
let demoStep = 0;
return {
reset() {
opened = false; burst = false;
zone.classList.remove("is-open");
paintFoil();
particles = [];
demoStep = 0;
},
runDemo(tMod: number) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
const p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
checkThreshold();
}
},
step(dt: number) { if (!reduce) stepParticles(dt); drawParticles(); },
revealAll() { scratchAt(FW / 2, FH / 2); openCard(); },
dispose() {
zone.removeEventListener("pointerdown", onDown);
zone.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
}
};
}
const cards = [makeCard(0), makeCard(1), makeCard(2)];
function onReveal() {
root!.classList.remove("is-demo");
for (let i = 0; i < cards.length; i += 1) cards[i].revealAll();
}
revealBtn.addEventListener("click", onReveal);
const DEMO_LOOP_MS = 4600;
if (reduce) root.classList.remove("is-demo");
let elapsed = 0;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce && root!.classList.contains("is-demo")) {
elapsed += dt;
const tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) { for (let i = 0; i < cards.length; i += 1) cards[i].reset(); }
for (let j = 0; j < cards.length; j += 1) cards[j].runDemo(tMod);
}
for (let k = 0; k < cards.length; k += 1) cards[k].step(dt);
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
revealBtn.removeEventListener("click", onReveal);
for (let i = 0; i < cards.length; i += 1) cards[i].dispose();
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.scv} is-demo`} ref={rootRef}>
<p className={styles.scv__label}>스크래치 카드 3장 세트</p>
<div className={styles.scv__grid}>
<div className={styles.scv__card}>
<div className={styles.scv__zone} ref={(el: HTMLDivElement | null) => { zoneRefs.current![0] = el; }}>
<div className={styles.scv__prize}><p className={styles.scv__prizeTitle}>10%</p><p className={styles.scv__prizeSub}>할인 쿠폰</p></div>
<canvas className={styles.scv__foil} ref={(el: HTMLCanvasElement | null) => { foilRefs.current![0] = el; }} width={150} height={120} aria-hidden="true" />
<canvas className={styles.scv__fx} ref={(el: HTMLCanvasElement | null) => { fxRefs.current![0] = el; }} width={150} height={120} aria-hidden="true" />
</div>
</div>
<div className={styles.scv__card}>
<div className={styles.scv__zone} ref={(el: HTMLDivElement | null) => { zoneRefs.current![1] = el; }}>
<div className={styles.scv__prize}><p className={styles.scv__prizeTitle}>무료배송</p><p className={styles.scv__prizeSub}>쿠폰</p></div>
<canvas className={styles.scv__foil} ref={(el: HTMLCanvasElement | null) => { foilRefs.current![1] = el; }} width={150} height={120} aria-hidden="true" />
<canvas className={styles.scv__fx} ref={(el: HTMLCanvasElement | null) => { fxRefs.current![1] = el; }} width={150} height={120} aria-hidden="true" />
</div>
</div>
<div className={styles.scv__card}>
<div className={styles.scv__zone} ref={(el: HTMLDivElement | null) => { zoneRefs.current![2] = el; }}>
<div className={styles.scv__prize}><p className={styles.scv__prizeTitle}>적립금</p><p className={styles.scv__prizeSub}>2,000원</p></div>
<canvas className={styles.scv__foil} ref={(el: HTMLCanvasElement | null) => { foilRefs.current![2] = el; }} width={150} height={120} aria-hidden="true" />
<canvas className={styles.scv__fx} ref={(el: HTMLCanvasElement | null) => { fxRefs.current![2] = el; }} width={150} height={120} aria-hidden="true" />
</div>
</div>
</div>
<button type="button" className={styles.scv__reveal} ref={revealBtnRef}>전부 한 번에 보기</button>
<p className={styles.scv__live} ref={liveRef} aria-live="polite"></p>
</div>
</main>
);
}09 Hero Scene Arranging Several Cards on One Screen — Measured area 15.311% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · Scratch Card Reveal: 9 Pixel-Calculated Patterns
The post this demo lives in
Scratch Card Reveal: 9 Pixel-Calculated PatternsObservation log 09:00 — a fingertip lands and item 03's foil flakes catch gravity. 09:04 — item 06's progress ring stall See all demos in this post


