03Foil Flakes That Fly Off Under Gravity
Flakes broken off at the scratch point pick up gravity and spin, flying off past the card's edge.
Best forBirthday coupon scratch event
spawnFlakestepFlakesFLAKE_LIFE_MS

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">Scratch card</p>
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">Birthday coupon</p>
<p class="scv__prizeSub">Flakes fly off with every scratch</p>
</div>
<canvas class="scv__foil" id="foil" width="280" height="150" aria-hidden="true"></canvas>
<canvas class="scv__flakes" id="flakes" width="280" height="150" aria-hidden="true"></canvas>
</div>
<button type="button" class="scv__reveal" id="revealBtn">Reveal instantly</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 03 — spawnFlake() 가 긁는 지점마다 작은 조각을 만들고 stepFlakes() 가
// FLAKE_GRAVITY_PX_S2 로 낙하·회전을 적분한다. 조각 캔버스(scv__flakes)는 은박 캔버스 위에
// pointer-events:none 으로 겹쳐 긁기 입력을 가리지 않는다.
@include stage($stage-orange);
.scv { display: flex; justify-content: center; width: 100%; }
.scv__card {
width: min(100%, 300px); background: $subject-blue-deep; color: $subject-cream;
border-radius: $r-card; box-shadow: $shadow-raised; padding: $sp-4;
}
.scv__label { margin: 0 0 $sp-2; font-weight: 800; font-size: 12px; letter-spacing: .04em; }
.scv__zone {
position: relative; border-radius: $r-control; overflow: hidden;
aspect-ratio: 280 / 150; 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-2; z-index: 0;
}
.scv__prizeTitle { margin: 0; font-weight: 800; font-size: clamp(18px, 6vw, 22px); color: $subject-blue-deep; }
.scv__prizeSub { margin: 0; font-weight: 700; font-size: 11px; color: rgba(23, 20, 26, .68); }
.scv__foil { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; z-index: 1; }
.scv__flakes { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 2; }
.scv.is-open .scv__foil { visibility: hidden; }
.scv__reveal {
margin-top: $sp-2; 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: $sp-1 0 0; min-height: 1em; font-weight: 700; font-size: 10px; color: $subject-cream; }
@media (max-width: 340px) {
.scv__card { padding: $sp-3; }
}
@media (max-height: 220px) {
.stage { padding: $sp-2; }
.scv__card { padding: $sp-2 $sp-3; }
.scv__label { margin: 0 0 $sp-1; }
.scv__zone { aspect-ratio: 280 / 88; }
.scv__reveal { margin-top: $sp-1; }
.scv__live { margin-top: $sp-1; 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.scv {
display: flex;
justify-content: center;
width: 100%;
}
.scv__card {
width: min(100%, 300px);
background: #0a4fe8;
color: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px;
}
.scv__label {
margin: 0 0 8px;
font-weight: 800;
font-size: 12px;
letter-spacing: 0.04em;
}
.scv__zone {
position: relative;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 280/150;
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: 8px;
z-index: 0;
}
.scv__prizeTitle {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 6vw, 22px);
color: #0a4fe8;
}
.scv__prizeSub {
margin: 0;
font-weight: 700;
font-size: 11px;
color: rgba(23, 20, 26, 0.68);
}
.scv__foil {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: none;
z-index: 1;
}
.scv__flakes {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
}
.scv.is-open .scv__foil {
visibility: hidden;
}
.scv__reveal {
margin-top: 8px;
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: 4px 0 0;
min-height: 1em;
font-weight: 700;
font-size: 10px;
color: #fff7e6;
}
@media (max-width: 340px) {
.scv__card {
padding: 12px;
}
}
@media (max-height: 220px) {
.stage {
padding: 8px;
}
.scv__card {
padding: 8px 12px;
}
.scv__label {
margin: 0 0 4px;
}
.scv__zone {
aspect-ratio: 280/88;
}
.scv__reveal {
margin-top: 4px;
}
.scv__live {
margin-top: 4px;
min-height: 0.9em;
}
}
@media (prefers-reduced-motion: reduce) {
.scv {
cursor: default;
}
}(function () {
// 기법: 긁는 지점마다 spawnFlake() 로 작은 은박 조각을 만들어 중력(FLAKE_GRAVITY_PX_S2)과
// 각속도를 준다. stepFlakes() 가 매 프레임 속도·회전을 적분해 조각이 카드 경계 밖으로
// 튕겨 나가며 사라지게 한다 — 파티클 엔진 없이 배열 하나로 발생·낙하·소멸을 계산한다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var flakesCanvas = document.getElementById('flakes');
var fctx = flakesCanvas.getContext('2d');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var FW = foil.width, FH = foil.height;
var SCRATCH_BRUSH_R = 15;
var FLAKE_GRAVITY_PX_S2 = 400;
var FLAKE_LIFE_MS = 900;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var foilRand = seedRand(424031);
var flakeRand = seedRand(424032);
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, FW, FH);
var grad = ctx.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, '#d8863f');
grad.addColorStop(.5, '#f3c98a');
grad.addColorStop(1, '#c9752c');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FW, FH);
ctx.strokeStyle = 'rgba(23,20,26,.16)';
ctx.lineWidth = 1;
for (var i = 0; i < 20; i += 1) {
var x = foilRand() * FW, y = foilRand() * FH;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 22 - 11), y + (foilRand() * 8 - 4));
ctx.stroke();
}
}
var flakes = [];
function spawnFlake(x, y, vx, vy) {
if (reduce) return;
flakes.push({
x: x, y: y, vx: vx, vy: vy,
rot: flakeRand() * Math.PI * 2,
angVel: (flakeRand() - .5) * 10,
born: 0, size: 3 + flakeRand() * 3
});
}
function stepFlakes(dtMs, nowMs) {
var dtS = dtMs / 1000;
var alive = [];
for (var i = 0; i < flakes.length; i += 1) {
var f = flakes[i];
f.vy += FLAKE_GRAVITY_PX_S2 * dtS;
f.x += f.vx * dtS;
f.y += f.vy * dtS;
f.rot += f.angVel * dtS;
f.born += dtMs;
if (f.born < FLAKE_LIFE_MS && f.x > -8 && f.x < FW + 8 && f.y < FH + 8) alive.push(f);
}
flakes = alive;
}
function drawFlakes() {
fctx.clearRect(0, 0, FW, FH);
for (var i = 0; i < flakes.length; i += 1) {
var f = flakes[i];
var fade = Math.max(0, 1 - f.born / FLAKE_LIFE_MS);
fctx.save();
fctx.translate(f.x, f.y);
fctx.rotate(f.rot);
fctx.fillStyle = 'rgba(243,201,138,' + fade + ')';
fctx.fillRect(-f.size / 2, -f.size / 2, f.size, f.size);
fctx.restore();
}
}
function scratchAt(x, y, vx, vy) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
spawnFlake(x, y, vx, vy - 30);
spawnFlake(x + (flakeRand() - .5) * 6, y + (flakeRand() - .5) * 6, -vx * .6, vy - 60);
}
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;
}
var opened = false;
function openCard() {
if (opened) return;
opened = true;
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(0, 0, FW, FH);
root.classList.add('is-open');
live.textContent = 'The birthday coupon is revealed';
}
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 reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var dragging = false, lastPos = null;
zone.addEventListener('pointerdown', function (e) {
if (opened) return;
root.classList.remove('is-demo');
dragging = true;
var p = pos(e);
scratchAt(p.x, p.y, 0, 0);
lastPos = p;
if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (!dragging || opened) return;
var p = pos(e);
var vx = lastPos ? (p.x - lastPos.x) * 6 : 0;
var vy = lastPos ? (p.y - lastPos.y) * 6 : 0;
scratchAt(p.x, p.y, vx, vy);
lastPos = p;
if (clearedRatio() > .85) openCard();
});
window.addEventListener('pointerup', function () { dragging = false; lastPos = null; });
revealBtn.addEventListener('click', function () { root.classList.remove('is-demo'); openCard(); });
// 격자 미리보기 — 같은 scratchAt() 경로를 스케줄로 호출해 조각을 계속 만든다
var demoRand = seedRand(424033);
var demoPath = [];
(function buildDemoPath() {
for (var i = 0; i < 22; i += 1) {
demoPath.push({
t: 230 + i * 82,
x: (FW * 0.14) + (i % 6) * (FW * 0.15) + (demoRand() - .5) * 6,
y: (FH * 0.24) + Math.floor(i / 6) * (FH * 0.26) + (demoRand() - .5) * 6,
vx: (demoRand() - .5) * 90,
vy: -40 - demoRand() * 40
});
}
})();
var DEMO_LOOP_MS = 3800;
var demoStep = 0;
function resetDemo() {
opened = false;
root.classList.remove('is-open');
paintFoil();
flakes = [];
demoStep = 0;
}
function runDemo(tMod) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
var p = demoPath[demoStep];
scratchAt(p.x, p.y, p.vx, p.vy);
demoStep += 1;
}
if (clearedRatio() > .85) openCard();
}
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) resetDemo();
runDemo(tMod);
}
if (!reduce) stepFlakes(dt, elapsed);
drawFlakes();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealFlakeGravityParticle.module.scss";
type Flake = { x: number; y: number; vx: number; vy: number; rot: number; angVel: number; born: number; size: number };
export default function ScratchCardRevealFlakeGravityParticle() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = useRef<HTMLCanvasElement>(null);
const flakesRef = useRef<HTMLCanvasElement>(null);
const revealBtnRef = useRef<HTMLButtonElement>(null);
const liveRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const zone = zoneRef.current;
const foil = foilRef.current;
const flakesCanvas = flakesRef.current;
const revealBtn = revealBtnRef.current;
const live = liveRef.current;
if (!root || !zone || !foil || !flakesCanvas || !revealBtn || !live) return;
const ctx = foil.getContext("2d");
const fctx = flakesCanvas.getContext("2d");
if (!ctx || !fctx) return;
const FW = foil.width, FH = foil.height;
const SCRATCH_BRUSH_R = 15;
const FLAKE_GRAVITY_PX_S2 = 400;
const FLAKE_LIFE_MS = 900;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const foilRand = seedRand(424031);
const flakeRand = seedRand(424032);
function paintFoil() {
ctx!.globalCompositeOperation = "source-over";
ctx!.clearRect(0, 0, FW, FH);
const grad = ctx!.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, "#d8863f");
grad.addColorStop(.5, "#f3c98a");
grad.addColorStop(1, "#c9752c");
ctx!.fillStyle = grad;
ctx!.fillRect(0, 0, FW, FH);
ctx!.strokeStyle = "rgba(23,20,26,.16)";
ctx!.lineWidth = 1;
for (let i = 0; i < 20; i += 1) {
const x = foilRand() * FW, y = foilRand() * FH;
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x + (foilRand() * 22 - 11), y + (foilRand() * 8 - 4));
ctx!.stroke();
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let flakes: Flake[] = [];
function spawnFlake(x: number, y: number, vx: number, vy: number) {
if (reduce) return;
flakes.push({
x, y, vx, vy,
rot: flakeRand() * Math.PI * 2,
angVel: (flakeRand() - .5) * 10,
born: 0, size: 3 + flakeRand() * 3
});
}
function stepFlakes(dtMs: number) {
const dtS = dtMs / 1000;
const alive: Flake[] = [];
for (let i = 0; i < flakes.length; i += 1) {
const f = flakes[i];
f.vy += FLAKE_GRAVITY_PX_S2 * dtS;
f.x += f.vx * dtS;
f.y += f.vy * dtS;
f.rot += f.angVel * dtS;
f.born += dtMs;
if (f.born < FLAKE_LIFE_MS && f.x > -8 && f.x < FW + 8 && f.y < FH + 8) alive.push(f);
}
flakes = alive;
}
function drawFlakes() {
fctx!.clearRect(0, 0, FW, FH);
for (let i = 0; i < flakes.length; i += 1) {
const f = flakes[i];
const fade = Math.max(0, 1 - f.born / FLAKE_LIFE_MS);
fctx!.save();
fctx!.translate(f.x, f.y);
fctx!.rotate(f.rot);
fctx!.fillStyle = "rgba(243,201,138," + fade + ")";
fctx!.fillRect(-f.size / 2, -f.size / 2, f.size, f.size);
fctx!.restore();
}
}
function scratchAt(x: number, y: number, vx: number, vy: number) {
ctx!.globalCompositeOperation = "destination-out";
ctx!.beginPath();
ctx!.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx!.fill();
spawnFlake(x, y, vx, vy - 30);
spawnFlake(x + (flakeRand() - .5) * 6, y + (flakeRand() - .5) * 6, -vx * .6, vy - 60);
}
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;
}
let opened = false;
function openCard() {
if (opened) return;
opened = true;
ctx!.globalCompositeOperation = "destination-out";
ctx!.fillRect(0, 0, FW, FH);
root!.classList.add("is-open");
live!.textContent = "생일 쿠폰이 열렸어요";
}
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;
let lastPos: { x: number; y: number } | null = null;
function onDown(e: PointerEvent) {
if (opened) return;
root!.classList.remove("is-demo");
dragging = true;
const p = pos(e);
scratchAt(p.x, p.y, 0, 0);
lastPos = p;
zone!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging || opened) return;
const p = pos(e);
const vx = lastPos ? (p.x - lastPos.x) * 6 : 0;
const vy = lastPos ? (p.y - lastPos.y) * 6 : 0;
scratchAt(p.x, p.y, vx, vy);
lastPos = p;
if (clearedRatio() > .85) openCard();
}
function onUp() { dragging = false; lastPos = null; }
zone.addEventListener("pointerdown", onDown);
zone.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
function onReveal() { root!.classList.remove("is-demo"); openCard(); }
revealBtn.addEventListener("click", onReveal);
const demoRand = seedRand(424033);
type DemoPt = { t: number; x: number; y: number; vx: number; vy: number };
const demoPath: DemoPt[] = [];
for (let i = 0; i < 22; i += 1) {
demoPath.push({
t: 230 + i * 82,
x: (FW * 0.14) + (i % 6) * (FW * 0.15) + (demoRand() - .5) * 6,
y: (FH * 0.24) + Math.floor(i / 6) * (FH * 0.26) + (demoRand() - .5) * 6,
vx: (demoRand() - .5) * 90,
vy: -40 - demoRand() * 40
});
}
const DEMO_LOOP_MS = 3800;
let demoStep = 0;
function resetDemo() {
opened = false;
root!.classList.remove("is-open");
paintFoil();
flakes = [];
demoStep = 0;
}
function runDemo(tMod: number) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
const p = demoPath[demoStep];
scratchAt(p.x, p.y, p.vx, p.vy);
demoStep += 1;
}
if (clearedRatio() > .85) openCard();
}
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) resetDemo();
runDemo(tMod);
}
if (!reduce) stepFlakes(dt);
drawFlakes();
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
zone.removeEventListener("pointerdown", onDown);
zone.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
revealBtn.removeEventListener("click", onReveal);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.scv} is-demo`} ref={rootRef}>
<div className={styles.scv__card}>
<p className={styles.scv__label}>스크래치 카드</p>
<div className={styles.scv__zone} ref={zoneRef}>
<div className={styles.scv__prize}>
<p className={styles.scv__prizeTitle}>생일 쿠폰</p>
<p className={styles.scv__prizeSub}>긁을 때마다 조각이 튀어 나가요</p>
</div>
<canvas className={styles.scv__foil} ref={foilRef} width={280} height={150} aria-hidden="true" />
<canvas className={styles.scv__flakes} ref={flakesRef} width={280} height={150} aria-hidden="true" />
</div>
<button type="button" className={styles.scv__reveal} ref={revealBtnRef}>한 번에 보기</button>
<p className={styles.scv__live} ref={liveRef} aria-live="polite"></p>
</div>
</div>
</main>
);
}03 Foil Flakes That Fly Off Under Gravity — Measured area 12.323% · moving frames 21/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


