スクラッチカード リビール 9パターン — ピクセル計算で仕上げる
観察記録 09:00 — 指先が触れた瞬間、03番の箔の破片が重力を受けて飛び散る。09:04 — 06番の進行リングが62%で止まる。09:07 — 09番の画面上のカードが全部同時に開く。9パターンの「スクラッチカード」セットです。
02 scratch-card-reveal-foil-noise-texture
03 scratch-card-reveal-flake-gravity-particle
04 scratch-card-reveal-radial-wipe-lerp
05 scratch-card-reveal-multitouch-pointer
06 scratch-card-reveal-progress-ring-sync
07 scratch-card-reveal-prize-confetti-burst
08 scratch-card-reveal-keyboard-fallback
09 scratch-card-reveal-hero-multi-card-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ピクセル計測で面積を測り自動的にめくれるカード
- 02 毎回異なる柄で描かれる箔の質感
- 03 重力を受けて飛び散る箔の破片
- 04 中心から円形に広がるリビール半径
- 05 二本の指がそれぞれ消すマルチタッチスクラッチ
- 06 削った割合に合わせて満ちる進行リング
- 07 完全にめくれると弾ける紙吹雪
- 08 矢印キーでも同じ結果を開く代替経路
- 09 複数枚を一画面に並べたヒーロー場面
スクラッチカード 9パターン — 消した面積の値ひとつがリビールをどう変えるか
9本すべてが同じ問いに、それぞれ違う数値で答えます。今どれだけ箔が消されているか、です。01番はその比率をキャンバスのピクセルから直接読み取って閾値を超えると残りを自動でめくり、06番は同じ計算式をカードではなくリングの充填に使い、07番は同じ比率が100%に近づくのを待って紙吹雪を弾けさせます。9本のどれも物理演算ライブラリやトゥイーンプラグインには頼っていません — getImageData・lerp・シード付き擬似乱数生成器だけで完結しています。
01ピクセル計測で面積を測り自動的にめくれるカード
clearedPixelRatio() はキャンバスのアルファチャンネルを1ピクセルずつ走査し、不透明度40未満のピクセルを数えます。つまり「消した」割合はストロークの長さから推定するのではなく、実測です。その比率×100が SCRATCH_REVEAL_THRESHOLD_PCT を超えると autoPeelRemaining() が openCard() を呼び、残っていた箔を destination-out の一発の塗りで消し去ります。
<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">20%割引クーポン</p>
<p class="scv__prizeSub">こすって結果を確認しましょう</p>
</div>
<canvas class="scv__foil" id="foil" width="280" height="150" aria-hidden="true"></canvas>
</div>
<div class="scv__meter" aria-hidden="true"><div class="scv__meterFill" id="meterFill"></div></div>
<button type="button" class="scv__reveal" id="revealBtn">一気に見る</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 01 — 실제 긁기는 canvas destination-out. 긁힌 알파 픽셀 비율을
// clearedPixelRatio() 로 계산해 SCRATCH_REVEAL_THRESHOLD_PCT 를 넘으면 autoPeelRemaining() 이
// 나머지를 한 번에 지운다. 격자 미리보기(.is-demo)는 같은 함수를 스케줄로 호출한다.
@include stage($stage-ink);
.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-1; 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;
}
.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; }
.scv.is-open .scv__foil { visibility: hidden; }
.scv__meter { margin-top: $sp-1; height: $sp-1; border-radius: $r-pill; background: rgba(255, 255, 255, .28); overflow: hidden; }
.scv__meterFill { height: 100%; width: 0%; background: $subject-cream; border-radius: inherit; }
.scv__reveal {
margin-top: $sp-1; 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; }
.scv__prizeTitle { font-size: 16px; }
}
@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 / 76; }
.scv__meter { margin-top: $sp-1; }
.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: #17141a;
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 4px;
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;
}
.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;
}
.scv.is-open .scv__foil {
visibility: hidden;
}
.scv__meter {
margin-top: 4px;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
overflow: hidden;
}
.scv__meterFill {
height: 100%;
width: 0%;
background: #fff7e6;
border-radius: inherit;
}
.scv__reveal {
margin-top: 4px;
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;
}
.scv__prizeTitle {
font-size: 16px;
}
}
@media (max-height: 220px) {
.stage {
padding: 8px;
}
.scv__card {
padding: 8px 12px;
}
.scv__label {
margin: 0 0 4px;
}
.scv__zone {
aspect-ratio: 280/76;
}
.scv__meter {
margin-top: 4px;
}
.scv__reveal {
margin-top: 4px;
}
.scv__live {
margin-top: 4px;
min-height: 0.9em;
}
}
@media (prefers-reduced-motion: reduce) {
.scv {
cursor: default;
}
}(function () {
// 기법: 긁을 때마다 canvas getImageData 로 지워진 알파 픽셀 수를 세어 전체 대비 비율을
// 계산한다. 그 비율이 SCRATCH_REVEAL_THRESHOLD_PCT 를 넘으면 나머지 은박을 한 번에
// destination-out 으로 지워 자동으로 카드를 연다 — 픽셀 계산 하나가 판정을 만든다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var meterFill = document.getElementById('meterFill');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var SCRATCH_REVEAL_THRESHOLD_PCT = 50;
var SCRATCH_BRUSH_R = 15;
var opened = false;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var foilRand = seedRand(424011);
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, foil.width, foil.height);
var grad = ctx.createLinearGradient(0, 0, foil.width, foil.height);
grad.addColorStop(0, '#cfd3dd');
grad.addColorStop(.5, '#eef0f5');
grad.addColorStop(1, '#b9bdc9');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, foil.width, foil.height);
ctx.strokeStyle = 'rgba(23,20,26,.16)';
ctx.lineWidth = 1;
for (var i = 0; i < 22; i += 1) {
var x = foilRand() * foil.width, y = foilRand() * foil.height;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 22 - 11), y + (foilRand() * 8 - 4));
ctx.stroke();
}
}
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
}
function clearedPixelRatio() {
var data = ctx.getImageData(0, 0, foil.width, foil.height).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 updateMeter(ratio) {
meterFill.style.width = Math.round(ratio * 100) + '%';
}
function openCard() {
if (opened) return;
opened = true;
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(0, 0, foil.width, foil.height);
updateMeter(1);
root.classList.add('is-open');
live.textContent = '20%割引クーポンが開きました';
}
function autoPeelRemaining(ratio) {
if (ratio * 100 >= SCRATCH_REVEAL_THRESHOLD_PCT) openCard();
}
function pos(e) {
var r = foil.getBoundingClientRect();
return { x: (e.clientX - r.left) * (foil.width / r.width), y: (e.clientY - r.top) * (foil.height / r.height) };
}
paintFoil();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
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);
var ratio = clearedPixelRatio();
updateMeter(ratio);
autoPeelRemaining(ratio);
if (foil.setPointerCapture) foil.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (!dragging || opened) return;
var p = pos(e);
scratchAt(p.x, p.y);
var ratio = clearedPixelRatio();
updateMeter(ratio);
autoPeelRemaining(ratio);
});
window.addEventListener('pointerup', function () { dragging = false; });
revealBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
openCard();
});
// 격자 미리보기 — 드래그 없이 같은 scratchAt·clearedPixelRatio 경로를 스케줄대로 호출한다
var demoRand = seedRand(424012);
var demoPath = [];
(function buildDemoPath() {
var n = 24;
for (var i = 0; i < n; i += 1) {
demoPath.push({
t: 220 + i * 78,
x: (foil.width * 0.16) + (i % 6) * (foil.width * 0.145) + (demoRand() - .5) * 8,
y: (foil.height * 0.26) + Math.floor(i / 6) * (foil.height * 0.24) + (demoRand() - .5) * 8
});
}
})();
var DEMO_LOOP_MS = 3600;
var demoStep = 0;
function resetDemo() {
opened = false;
root.classList.remove('is-open');
paintFoil();
updateMeter(0);
demoStep = 0;
}
function runDemo(tMod) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
var p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
}
var ratio = clearedPixelRatio();
updateMeter(ratio);
autoPeelRemaining(ratio);
}
if (reduce) root.classList.remove('is-demo');
var elapsed = 0;
var 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);
}
requestAnimationFrame(tick);
}
updateMeter(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealPixelThresholdAutopeel.module.scss";
export default function ScratchCardRevealPixelThresholdAutopeel() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = useRef<HTMLCanvasElement>(null);
const meterRef = useRef<HTMLDivElement>(null);
const liveRef = useRef<HTMLParagraphElement>(null);
const revealBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const zone = zoneRef.current;
const foil = foilRef.current;
const meterFill = meterRef.current;
const live = liveRef.current;
const revealBtn = revealBtnRef.current;
if (!root || !zone || !foil || !meterFill || !live || !revealBtn) return;
const ctx = foil.getContext("2d");
if (!ctx) return;
const SCRATCH_REVEAL_THRESHOLD_PCT = 50;
const SCRATCH_BRUSH_R = 15;
let opened = false;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const foilRand = seedRand(424011);
function paintFoil() {
ctx!.globalCompositeOperation = "source-over";
ctx!.clearRect(0, 0, foil!.width, foil!.height);
const grad = ctx!.createLinearGradient(0, 0, foil!.width, foil!.height);
grad.addColorStop(0, "#cfd3dd");
grad.addColorStop(.5, "#eef0f5");
grad.addColorStop(1, "#b9bdc9");
ctx!.fillStyle = grad;
ctx!.fillRect(0, 0, foil!.width, foil!.height);
ctx!.strokeStyle = "rgba(23,20,26,.16)";
ctx!.lineWidth = 1;
for (let i = 0; i < 22; i += 1) {
const x = foilRand() * foil!.width, y = foilRand() * foil!.height;
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x + (foilRand() * 22 - 11), y + (foilRand() * 8 - 4));
ctx!.stroke();
}
}
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();
}
function clearedPixelRatio() {
const data = ctx!.getImageData(0, 0, foil!.width, foil!.height).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 updateMeter(ratio: number) {
meterFill!.style.width = Math.round(ratio * 100) + "%";
}
function openCard() {
if (opened) return;
opened = true;
ctx!.globalCompositeOperation = "destination-out";
ctx!.fillRect(0, 0, foil!.width, foil!.height);
updateMeter(1);
root!.classList.add("is-open");
live!.textContent = "20% 할인 쿠폰이 열렸어요";
}
function autoPeelRemaining(ratio: number) {
if (ratio * 100 >= SCRATCH_REVEAL_THRESHOLD_PCT) openCard();
}
function pos(e: PointerEvent) {
const r = foil!.getBoundingClientRect();
return { x: (e.clientX - r.left) * (foil!.width / r.width), y: (e.clientY - r.top) * (foil!.height / r.height) };
}
paintFoil();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
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);
const ratio = clearedPixelRatio();
updateMeter(ratio);
autoPeelRemaining(ratio);
foil!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging || opened) return;
const p = pos(e);
scratchAt(p.x, p.y);
const ratio = clearedPixelRatio();
updateMeter(ratio);
autoPeelRemaining(ratio);
}
function onUp() { dragging = false; }
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(424012);
type DemoPt = { t: number; x: number; y: number };
const demoPath: DemoPt[] = [];
for (let i = 0; i < 24; i += 1) {
demoPath.push({
t: 220 + i * 78,
x: (foil.width * 0.16) + (i % 6) * (foil.width * 0.145) + (demoRand() - .5) * 8,
y: (foil.height * 0.26) + Math.floor(i / 6) * (foil.height * 0.24) + (demoRand() - .5) * 8
});
}
const DEMO_LOOP_MS = 3600;
let demoStep = 0;
function resetDemo() {
opened = false;
root!.classList.remove("is-open");
paintFoil();
updateMeter(0);
demoStep = 0;
}
function runDemo(tMod: number) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
const p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
}
const ratio = clearedPixelRatio();
updateMeter(ratio);
autoPeelRemaining(ratio);
}
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);
}
raf = requestAnimationFrame(tick);
}
updateMeter(0);
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}>20% 할인 쿠폰</p>
<p className={styles.scv__prizeSub}>긁어서 결과를 확인하세요</p>
</div>
<canvas className={styles.scv__foil} ref={foilRef} width={280} height={150} aria-hidden="true" />
</div>
<div className={styles.scv__meter} aria-hidden="true"><div className={styles.scv__meterFill} ref={meterRef} /></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>
);
}01 · ピクセル計測で面積を測り自動的にめくれるカード — 計測 累積面積 12.103% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function clearedPixelRatio() {
var data = ctx.getImageData(0, 0, foil.width, foil.height).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 updateMeter(ratio) {
meterFill.style.width = Math.round(ratio * 100) + '%';
}
function openCard() {
if (opened) return;
opened = true;
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(0, 0, foil.width, foil.height);
updateMeter(1);
root.classList.add('is-open');
live.textContent = '20%割引クーポンが開きました';
}
function autoPeelRemaining(ratio) {
if (ratio * 100 >= SCRATCH_REVEAL_THRESHOLD_PCT) openCard();
01番はフレームの12.1%を塗り替え、平均強度40.4、測定した15/23 フレームで動きがありました。
02毎回異なる柄で描かれる箔の質感
buildNoiseFoil(seed) はカードごとに一度だけルックアップテーブルを作り、valueNoise2D() で4つの noiseAt() の角を双線形補間してサンプリングします。そのため異なるシードから描かれた2枚のカードで光沢の柄が二度と同じになりません。結果はCSSグラデーションではなく ImageData バッファに1ピクセルずつ書き込まれます。

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">ギフトクーポン</p>
<p class="scv__prizeSub">新しいカードごとに銀箔の模様が変わります</p>
</div>
<canvas class="scv__foil" id="foil" width="280" height="150" aria-hidden="true"></canvas>
</div>
<div class="scv__btnRow">
<button type="button" class="scv__reveal" id="revealBtn">一気に見る</button>
<button type="button" class="scv__redo" id="redoBtn">新しいカードを作る</button>
</div>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 02 — buildNoiseFoil(seed) 가 2D 값 노이즈(valueNoise2D)로 은박 표면을 계산해
// 그린다. 아이들 상태에서는 sheen 하이라이트가 오가고, 긁기 시작하면 이후로는 다시 칠하지
// 않고 destination-out 구멍만 쌓인다 — 그래서 새 카드를 만들 때만 seed 를 바꿔 다시 계산한다.
@include stage($stage-yellow);
.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(17px, 5.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.is-open .scv__foil { visibility: hidden; }
.scv__btnRow { display: flex; gap: $sp-2; margin-top: $sp-2; }
.scv__reveal, .scv__redo {
flex: 0 0 auto; border: 0; cursor: pointer; border-radius: $r-pill;
font-family: inherit; font-weight: 800; font-size: 10.5px; padding: $sp-1 $sp-3; line-height: 1.3;
}
.scv__reveal { background: $subject-cream; color: $subject-blue-deep; }
.scv__redo { background: rgba(255, 255, 255, .18); color: $subject-cream; }
.scv__reveal:focus-visible, .scv__redo: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; }
.scv__btnRow { flex-wrap: wrap; }
}
@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: #ffd23f;
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(17px, 5.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.is-open .scv__foil {
visibility: hidden;
}
.scv__btnRow {
display: flex;
gap: 8px;
margin-top: 8px;
}
.scv__reveal, .scv__redo {
flex: 0 0 auto;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-weight: 800;
font-size: 10.5px;
padding: 4px 12px;
line-height: 1.3;
}
.scv__reveal {
background: #fff7e6;
color: #0a4fe8;
}
.scv__redo {
background: rgba(255, 255, 255, 0.18);
color: #fff7e6;
}
.scv__reveal:focus-visible, .scv__redo: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;
}
.scv__btnRow {
flex-wrap: wrap;
}
}
@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 () {
// 기법: 2D 값 노이즈 함수(valueNoise2D)로 은박 표면의 밝기를 픽셀마다 계산해 금속 질감을
// 만든다. buildNoiseFoil(seed) 는 seed 하나로 결정된 무늬 하나를 만들고, "新しいカードを作る" 는
// seed 를 바꿔 다른 무늬를 다시 계산한다 — 이미지 파일이 아니라 그때그때 계산으로 그린다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var revealBtn = document.getElementById('revealBtn');
var redoBtn = document.getElementById('redoBtn');
var live = document.getElementById('live');
var FOIL_NOISE_DIM = 22;
var SCRATCH_BRUSH_R = 15;
var FW = foil.width, FH = foil.height;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function buildNoiseTable(seed) {
var rand = seedRand(seed);
var table = new Float32Array(FOIL_NOISE_DIM * FOIL_NOISE_DIM);
for (var i = 0; i < table.length; i += 1) table[i] = rand();
return table;
}
function noiseAt(table, xi, yi) {
var wx = ((xi % FOIL_NOISE_DIM) + FOIL_NOISE_DIM) % FOIL_NOISE_DIM;
var wy = ((yi % FOIL_NOISE_DIM) + FOIL_NOISE_DIM) % FOIL_NOISE_DIM;
return table[wy * FOIL_NOISE_DIM + wx];
}
function valueNoise2D(table, x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(table, x0, y0), v10 = noiseAt(table, x0 + 1, y0);
var v01 = noiseAt(table, x0, y0 + 1), v11 = noiseAt(table, x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx, b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
function buildNoiseFoil(seed) {
var table = buildNoiseTable(seed);
var crackRand = seedRand(seed + 1);
var img = ctx.createImageData(FW, FH);
var d = img.data;
var scaleX = FOIL_NOISE_DIM / FW, scaleY = FOIL_NOISE_DIM / FH;
for (var y = 0; y < FH; y += 1) {
for (var x = 0; x < FW; x += 1) {
var n = valueNoise2D(table, x * scaleX * 2.4, y * scaleY * 2.4);
var lightness = 176 + n * 62;
var idx = (y * FW + x) * 4;
d[idx] = lightness * 0.98;
d[idx + 1] = lightness;
d[idx + 2] = lightness * 1.03;
d[idx + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
ctx.strokeStyle = 'rgba(23,20,26,.14)';
ctx.lineWidth = 1;
for (var i = 0; i < 16; i += 1) {
var sx = crackRand() * FW, sy = crackRand() * FH;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + (crackRand() * 26 - 13), sy + (crackRand() * 10 - 5));
ctx.stroke();
}
return img;
}
var seed = 424021;
var baseCache = null, baseSeed = null;
function getBase() {
if (baseSeed !== seed) { baseCache = buildNoiseFoil(seed); baseSeed = seed; }
return baseCache;
}
function renderIdle(sheenT) {
ctx.putImageData(getBase(), 0, 0);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
var sheenX = FW * sheenT;
var g = ctx.createLinearGradient(sheenX - 34, 0, sheenX + 34, FH);
g.addColorStop(0, 'rgba(255,255,255,0)');
g.addColorStop(.5, 'rgba(255,255,255,.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, FW, FH);
ctx.restore();
}
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
}
var opened = false, scratchStarted = false;
function openCard() {
if (opened) return;
opened = true;
scratchStarted = true;
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(0, 0, FW, FH);
root.classList.add('is-open');
live.textContent = 'ギフトクーポンが開きました';
}
function newCard() {
seed += 1;
opened = false;
scratchStarted = false;
root.classList.remove('is-open');
renderIdle(0);
live.textContent = '新しい銀箔の模様を計算しました';
}
function pos(e) {
var r = foil.getBoundingClientRect();
return { x: (e.clientX - r.left) * (FW / r.width), y: (e.clientY - r.top) * (FH / r.height) };
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var dragging = false;
zone.addEventListener('pointerdown', function (e) {
if (opened) return;
root.classList.remove('is-demo');
scratchStarted = true;
dragging = true;
var p = pos(e);
scratchAt(p.x, p.y);
if (foil.setPointerCapture) foil.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (!dragging || opened) return;
var p = pos(e);
scratchAt(p.x, p.y);
});
window.addEventListener('pointerup', function () { dragging = false; });
revealBtn.addEventListener('click', function () { root.classList.remove('is-demo'); openCard(); });
redoBtn.addEventListener('click', function () { root.classList.remove('is-demo'); newCard(); });
// 격자 미리보기 — 반짝임(is idle) 뒤 긁기 스케줄 실행 후 새 무늬로 넘어가는 순환
var demoRand = seedRand(424022);
var demoPath = [];
(function buildDemoPath() {
for (var i = 0; i < 18; i += 1) {
demoPath.push({
t: 1400 + i * 90,
x: (FW * 0.15) + (i % 6) * (FW * 0.15) + (demoRand() - .5) * 8,
y: (FH * 0.28) + Math.floor(i / 6) * (FH * 0.24) + (demoRand() - .5) * 8
});
}
})();
var DEMO_LOOP_MS = 4400;
var demoStep = 0;
function runDemo(tMod) {
if (tMod < 1400) {
renderIdle((Math.sin(tMod * 0.0028) + 1) / 2);
return;
}
if (!scratchStarted) scratchStarted = true;
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
var p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
}
}
function resetDemo() {
seed += 1;
opened = false;
scratchStarted = false;
root.classList.remove('is-open');
demoStep = 0;
}
if (reduce) root.classList.remove('is-demo');
var elapsed = 0;
var 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);
}
requestAnimationFrame(tick);
}
renderIdle(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealFoilNoiseTexture.module.scss";
export default function ScratchCardRevealFoilNoiseTexture() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = useRef<HTMLCanvasElement>(null);
const revealBtnRef = useRef<HTMLButtonElement>(null);
const redoBtnRef = useRef<HTMLButtonElement>(null);
const liveRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const zone = zoneRef.current;
const foil = foilRef.current;
const revealBtn = revealBtnRef.current;
const redoBtn = redoBtnRef.current;
const live = liveRef.current;
if (!root || !zone || !foil || !revealBtn || !redoBtn || !live) return;
const ctx = foil.getContext("2d");
if (!ctx) return;
const FOIL_NOISE_DIM = 22;
const SCRATCH_BRUSH_R = 15;
const FW = foil.width, FH = foil.height;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function smoothstep(t: number) { return t * t * (3 - 2 * t); }
function buildNoiseTable(seed: number) {
const rand = seedRand(seed);
const table = new Float32Array(FOIL_NOISE_DIM * FOIL_NOISE_DIM);
for (let i = 0; i < table.length; i += 1) table[i] = rand();
return table;
}
function noiseAt(table: Float32Array, xi: number, yi: number) {
const wx = ((xi % FOIL_NOISE_DIM) + FOIL_NOISE_DIM) % FOIL_NOISE_DIM;
const wy = ((yi % FOIL_NOISE_DIM) + FOIL_NOISE_DIM) % FOIL_NOISE_DIM;
return table[wy * FOIL_NOISE_DIM + wx];
}
function valueNoise2D(table: Float32Array, x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const tx = smoothstep(x - x0), ty = smoothstep(y - y0);
const v00 = noiseAt(table, x0, y0), v10 = noiseAt(table, x0 + 1, y0);
const v01 = noiseAt(table, x0, y0 + 1), v11 = noiseAt(table, x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * tx, b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
function buildNoiseFoil(seed: number) {
const table = buildNoiseTable(seed);
const crackRand = seedRand(seed + 1);
const img = ctx!.createImageData(FW, FH);
const d = img.data;
const scaleX = FOIL_NOISE_DIM / FW, scaleY = FOIL_NOISE_DIM / FH;
for (let y = 0; y < FH; y += 1) {
for (let x = 0; x < FW; x += 1) {
const n = valueNoise2D(table, x * scaleX * 2.4, y * scaleY * 2.4);
const lightness = 176 + n * 62;
const idx = (y * FW + x) * 4;
d[idx] = lightness * 0.98;
d[idx + 1] = lightness;
d[idx + 2] = lightness * 1.03;
d[idx + 3] = 255;
}
}
ctx!.putImageData(img, 0, 0);
ctx!.strokeStyle = "rgba(23,20,26,.14)";
ctx!.lineWidth = 1;
for (let i = 0; i < 16; i += 1) {
const sx = crackRand() * FW, sy = crackRand() * FH;
ctx!.beginPath();
ctx!.moveTo(sx, sy);
ctx!.lineTo(sx + (crackRand() * 26 - 13), sy + (crackRand() * 10 - 5));
ctx!.stroke();
}
return img;
}
let seed = 424021;
let baseCache: ImageData | null = null, baseSeed: number | null = null;
function getBase() {
if (baseSeed !== seed) { baseCache = buildNoiseFoil(seed); baseSeed = seed; }
return baseCache!;
}
function renderIdle(sheenT: number) {
ctx!.putImageData(getBase(), 0, 0);
ctx!.save();
ctx!.globalCompositeOperation = "lighter";
const sheenX = FW * sheenT;
const g = ctx!.createLinearGradient(sheenX - 34, 0, sheenX + 34, FH);
g.addColorStop(0, "rgba(255,255,255,0)");
g.addColorStop(.5, "rgba(255,255,255,.55)");
g.addColorStop(1, "rgba(255,255,255,0)");
ctx!.fillStyle = g;
ctx!.fillRect(0, 0, FW, FH);
ctx!.restore();
}
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();
}
let opened = false, scratchStarted = false;
function openCard() {
if (opened) return;
opened = true;
scratchStarted = true;
ctx!.globalCompositeOperation = "destination-out";
ctx!.fillRect(0, 0, FW, FH);
root!.classList.add("is-open");
live!.textContent = "선물 쿠폰이 열렸어요";
}
function newCard() {
seed += 1;
opened = false;
scratchStarted = false;
root!.classList.remove("is-open");
renderIdle(0);
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) };
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let dragging = false;
function onDown(e: PointerEvent) {
if (opened) return;
root!.classList.remove("is-demo");
scratchStarted = true;
dragging = true;
const p = pos(e);
scratchAt(p.x, p.y);
foil!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging || opened) return;
const p = pos(e);
scratchAt(p.x, p.y);
}
function onUp() { dragging = false; }
zone.addEventListener("pointerdown", onDown);
zone.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
function onReveal() { root!.classList.remove("is-demo"); openCard(); }
function onRedo() { root!.classList.remove("is-demo"); newCard(); }
revealBtn.addEventListener("click", onReveal);
redoBtn.addEventListener("click", onRedo);
const demoRand = seedRand(424022);
type DemoPt = { t: number; x: number; y: number };
const demoPath: DemoPt[] = [];
for (let i = 0; i < 18; i += 1) {
demoPath.push({
t: 1400 + i * 90,
x: (FW * 0.15) + (i % 6) * (FW * 0.15) + (demoRand() - .5) * 8,
y: (FH * 0.28) + Math.floor(i / 6) * (FH * 0.24) + (demoRand() - .5) * 8
});
}
const DEMO_LOOP_MS = 4400;
let demoStep = 0;
function runDemo(tMod: number) {
if (tMod < 1400) {
renderIdle((Math.sin(tMod * 0.0028) + 1) / 2);
return;
}
if (!scratchStarted) scratchStarted = true;
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
const p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
}
}
function resetDemo() {
seed += 1;
opened = false;
scratchStarted = false;
root!.classList.remove("is-open");
demoStep = 0;
}
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);
}
raf = requestAnimationFrame(tick);
}
renderIdle(0);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
zone.removeEventListener("pointerdown", onDown);
zone.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
revealBtn.removeEventListener("click", onReveal);
redoBtn.removeEventListener("click", onRedo);
};
}, []);
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" />
</div>
<div className={styles.scv__btnRow}>
<button type="button" className={styles.scv__reveal} ref={revealBtnRef}>한 번에 보기</button>
<button type="button" className={styles.scv__redo} ref={redoBtnRef}>새 카드 만들기</button>
</div>
<p className={styles.scv__live} ref={liveRef} aria-live="polite"></p>
</div>
</div>
</main>
);
}02 · 毎回異なる柄で描かれる箔の質感 — 計測 累積面積 28.594% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function valueNoise2D(table, x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
var v00 = noiseAt(table, x0, y0), v10 = noiseAt(table, x0 + 1, y0);
var v01 = noiseAt(table, x0, y0 + 1), v11 = noiseAt(table, x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * tx, b = v01 + (v11 - v01) * tx;
return a + (b - a) * ty;
}
function buildNoiseFoil(seed) {
var table = buildNoiseTable(seed);
var crackRand = seedRand(seed + 1);
var img = ctx.createImageData(FW, FH);
var d = img.data;
var scaleX = FOIL_NOISE_DIM / FW, scaleY = FOIL_NOISE_DIM / FH;
for (var y = 0; y < FH; y += 1) {
for (var x = 0; x < FW; x += 1) {
var n = valueNoise2D(table, x * scaleX * 2.4, y * scaleY * 2.4);
var lightness = 176 + n * 62;
var idx = (y * FW + x) * 4;
02番はフレームの28.6%を塗り替え、平均強度37.2、測定した17/23 フレームで動きがありました。
03重力を受けて飛び散る箔の破片
spawnFlake(x, y, vx, vy) は剥がれた破片一つ一つにシード付き生成器で回転と自転速度を与え、stepFlakes() が毎ティック FLAKE_GRAVITY_PX_S2 を縦速度に加算し続けて、FLAKE_LIFE_MS を過ぎるかカードの外へ漂い出るまでその破片を生かし続けます。prefers-reduced-motion が有効なときは spawnFlake() が即座に戻り、破片は一つも生成されません。

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">誕生日クーポン</p>
<p class="scv__prizeSub">こするたびに破片が飛び散ります</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">一気に見る</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 = '誕生日クーポンが開きました';
}
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 · 重力を受けて飛び散る箔の破片 — 計測 累積面積 12.323% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
03番はフレームの12.3%を塗り替え、平均強度82.1、測定した21/23 フレームで動きがありました。
04中心から円形に広がるリビール半径
指を押している間、press.r は毎フレーム WIPE_LERP_RATE の割合で WIPE_TARGET_R に近づいていきます — 直線的に大きくなるのではなくlerpで固定半径へ収束します。wipe() は毎ティックその時点の半径で円を消し、同じティックで clearedRatio() を確認して82%を超えればlerpの収束を待たずにカードを開きます。

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">割引クーポン</p>
<p class="scv__prizeSub">押した地点から円がだんだん大きくなります</p>
</div>
<canvas class="scv__foil" id="foil" width="280" height="150" aria-hidden="true"></canvas>
</div>
<button type="button" class="scv__reveal" id="revealBtn">一気に見る</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 04 — press.r 가 목표 반지름(WIPE_TARGET_R)까지 매 프레임
// lerp(WIPE_LERP_RATE) 로 가속 추적해 누르고만 있어도 원형으로 은박이 넓어진다.
@include stage($stage-paper);
.scv { display: flex; justify-content: center; width: 100%; }
.scv__card {
width: min(100%, 336px); background: $subject-blue-deep; color: $subject-cream;
border-radius: $r-card; box-shadow: $shadow-raised; padding: $sp-3 $sp-4;
}
.scv__label { margin: 0 0 $sp-1; font-weight: 800; font-size: 12px; letter-spacing: .04em; }
.scv__zone {
position: relative; border-radius: $r-control; overflow: hidden;
aspect-ratio: 280 / 150; background: #eef0f3;
}
.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.is-open .scv__foil { visibility: hidden; }
.scv__reveal {
margin-top: $sp-1; 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-ink; outline-offset: 2px; }
.scv__live { margin: 0; min-height: 0; 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: #ffffff;
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%, 336px);
background: #0a4fe8;
color: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px;
}
.scv__label {
margin: 0 0 4px;
font-weight: 800;
font-size: 12px;
letter-spacing: 0.04em;
}
.scv__zone {
position: relative;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 280/150;
background: #eef0f3;
}
.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.is-open .scv__foil {
visibility: hidden;
}
.scv__reveal {
margin-top: 4px;
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 #17141a;
outline-offset: 2px;
}
.scv__live {
margin: 0;
min-height: 0;
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 () {
// 기법: 누른 지점을 중심으로 반지름을 목표값까지 lerp(선형보간)로 매 프레임 가속 추적한다.
// 실제 드래그 없이 누르고 있기만 해도 반지름이 스스로 자라나 원형으로 은박이 사라진다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var FW = foil.width, FH = foil.height;
var WIPE_TARGET_R = Math.ceil(Math.sqrt(FW * FW + FH * FH) / 2); // 카드 대각선 절반 — 어느 지점을 눌러도 끝까지 덮는다
var WIPE_LERP_RATE = 1.5; // 확장 과정이 눈에 오래 보이도록 가속을 늦춘다
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var foilRand = seedRand(424041);
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, FW, FH);
var grad = ctx.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, '#dfe3ea');
grad.addColorStop(.5, '#f4f6f9');
grad.addColorStop(1, '#c9ced8');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FW, FH);
ctx.strokeStyle = 'rgba(23,20,26,.14)';
ctx.lineWidth = 1;
for (var i = 0; i < 18; i += 1) {
var x = foilRand() * FW, y = foilRand() * FH;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 20 - 10), y + (foilRand() * 8 - 4));
ctx.stroke();
}
}
var press = null; // {x,y,r}
function wipe() {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(press.x, press.y, press.r, 0, Math.PI * 2);
ctx.fill();
}
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 = '割引クーポンが開きました';
}
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 holding = false;
zone.addEventListener('pointerdown', function (e) {
if (opened) return;
root.classList.remove('is-demo');
holding = true;
var p = pos(e);
press = { x: p.x, y: p.y, r: 4 };
wipe();
if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (!holding || opened) return;
var p = pos(e);
press.x = p.x; press.y = p.y;
});
window.addEventListener('pointerup', function () { holding = false; });
revealBtn.addEventListener('click', function () { root.classList.remove('is-demo'); openCard(); });
// 카드 끝까지 원형 확장 → 다시 덮임 → 다른 지점에서 재확장을 한 루프 안에서 반복한다.
var demoRand = seedRand(424042);
var DEMO_POINTS = [
{ x: FW * (0.30 + demoRand() * 0.08), y: FH * (0.36 + demoRand() * 0.08) },
{ x: FW * (0.66 + demoRand() * 0.08), y: FH * (0.58 + demoRand() * 0.08) },
{ x: FW * (0.42 + demoRand() * 0.08), y: FH * (0.30 + demoRand() * 0.08) }
];
var DEMO_STARTS = [220, 950, 1680];
var DEMO_LOOP_MS = 3200;
var demoCycle = -1;
var demoPress = null;
function resetDemo() {
opened = false;
root.classList.remove('is-open');
paintFoil();
demoCycle = -1;
demoPress = null;
}
function runDemo(tMod, dtS) {
var next = demoCycle + 1;
if (next < DEMO_STARTS.length && tMod >= DEMO_STARTS[next]) {
demoCycle = next;
var p = DEMO_POINTS[demoCycle];
demoPress = { x: p.x, y: p.y, r: 4 };
opened = false;
root.classList.remove('is-open');
paintFoil(); // 이전 사이클에서 열렸던 은박을 다시 덮는다
}
if (demoPress && !opened) {
demoPress.r += (WIPE_TARGET_R - demoPress.r) * Math.min(1, WIPE_LERP_RATE * dtS);
press = demoPress;
wipe();
if (clearedRatio() > .82) 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);
var dtS = dt / 1000;
lastTs = ts;
if (holding && !opened && press) {
press.r += (WIPE_TARGET_R - press.r) * Math.min(1, WIPE_LERP_RATE * dtS);
wipe();
if (clearedRatio() > .82) openCard();
}
if (!reduce && root.classList.contains('is-demo')) {
elapsed += dt;
var tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) resetDemo();
runDemo(tMod, dtS);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealRadialWipeLerp.module.scss";
type Press = { x: number; y: number; r: number };
export default function ScratchCardRevealRadialWipeLerp() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = 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 revealBtn = revealBtnRef.current;
const live = liveRef.current;
if (!root || !zone || !foil || !revealBtn || !live) return;
const ctx = foil.getContext("2d");
if (!ctx) return;
const FW = foil.width, FH = foil.height;
const WIPE_TARGET_R = Math.ceil(Math.sqrt(FW * FW + FH * FH) / 2); // 카드 대각선 절반 — 어느 지점을 눌러도 끝까지 덮는다
const WIPE_LERP_RATE = 1.5; // 확장 과정이 눈에 오래 보이도록 가속을 늦춘다
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const foilRand = seedRand(424041);
function paintFoil() {
ctx!.globalCompositeOperation = "source-over";
ctx!.clearRect(0, 0, FW, FH);
const grad = ctx!.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, "#dfe3ea");
grad.addColorStop(.5, "#f4f6f9");
grad.addColorStop(1, "#c9ced8");
ctx!.fillStyle = grad;
ctx!.fillRect(0, 0, FW, FH);
ctx!.strokeStyle = "rgba(23,20,26,.14)";
ctx!.lineWidth = 1;
for (let i = 0; i < 18; i += 1) {
const x = foilRand() * FW, y = foilRand() * FH;
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x + (foilRand() * 20 - 10), y + (foilRand() * 8 - 4));
ctx!.stroke();
}
}
let press: Press | null = null;
function wipe() {
ctx!.globalCompositeOperation = "destination-out";
ctx!.beginPath();
ctx!.arc(press!.x, press!.y, press!.r, 0, Math.PI * 2);
ctx!.fill();
}
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();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let holding = false;
function onDown(e: PointerEvent) {
if (opened) return;
root!.classList.remove("is-demo");
holding = true;
const p = pos(e);
press = { x: p.x, y: p.y, r: 4 };
wipe();
zone!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!holding || opened || !press) return;
const p = pos(e);
press.x = p.x; press.y = p.y;
}
function onUp() { holding = false; }
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(424042);
const DEMO_POINTS: Array<{ x: number; y: number }> = [
{ x: FW * (0.30 + demoRand() * 0.08), y: FH * (0.36 + demoRand() * 0.08) },
{ x: FW * (0.66 + demoRand() * 0.08), y: FH * (0.58 + demoRand() * 0.08) },
{ x: FW * (0.42 + demoRand() * 0.08), y: FH * (0.30 + demoRand() * 0.08) }
];
const DEMO_STARTS = [220, 950, 1680];
const DEMO_LOOP_MS = 3200;
let demoCycle = -1;
let demoPress: Press | null = null;
function resetDemo() {
opened = false;
root!.classList.remove("is-open");
paintFoil();
demoCycle = -1;
demoPress = null;
}
function runDemo(tMod: number, dtS: number) {
const next = demoCycle + 1;
if (next < DEMO_STARTS.length && tMod >= DEMO_STARTS[next]) {
demoCycle = next;
const p = DEMO_POINTS[demoCycle];
demoPress = { x: p.x, y: p.y, r: 4 };
opened = false;
root!.classList.remove("is-open");
paintFoil(); // 이전 사이클에서 열렸던 은박을 다시 덮는다
}
if (demoPress && !opened) {
demoPress.r += (WIPE_TARGET_R - demoPress.r) * Math.min(1, WIPE_LERP_RATE * dtS);
press = demoPress;
wipe();
if (clearedRatio() > .82) 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);
const dtS = dt / 1000;
lastTs = ts;
if (holding && !opened && press) {
press.r += (WIPE_TARGET_R - press.r) * Math.min(1, WIPE_LERP_RATE * dtS);
wipe();
if (clearedRatio() > .82) openCard();
}
if (!reduce && root!.classList.contains("is-demo")) {
elapsed += dt;
const tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) resetDemo();
runDemo(tMod, dtS);
}
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" />
</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>
);
}04 · 中心から円形に広がるリビール半径 — 計測 累積面積 23.424% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
var dtS = dt / 1000;
lastTs = ts;
if (holding && !opened && press) {
press.r += (WIPE_TARGET_R - press.r) * Math.min(1, WIPE_LERP_RATE * dtS);
wipe();
if (clearedRatio() > .82) openCard();
}
if (!reduce && root.classList.contains('is-demo')) {
elapsed += dt;
var tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) resetDemo();
runDemo(tMod, dtS);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
04番はフレームの23.4%を塗り替え、平均強度69.0、測定した23/23 フレームで動きがありました。
05二本の指がそれぞれ消すマルチタッチスクラッチ
event.pointerId をキーにした pointers マップこそ、スクラッチカード 手作りでも複数の指が同時に触れて正しく動く理由です。pointermove はそのポインター専用の直前座標を取り出し、scratchLine() で新しい座標まで線を引いてから座標を上書きし、clearedRatio() はどのポインターについても同じハンドラーの中で独立して呼ばれます。

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">交換クーポン</p>
<p class="scv__prizeSub">複数の指で同時にこすってみてください</p>
</div>
<canvas class="scv__foil" id="foil" width="280" height="150" aria-hidden="true"></canvas>
</div>
<button type="button" class="scv__reveal" id="revealBtn">一気に見る</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 05 — pointerdown 마다 e.pointerId 를 Map 키로 저장해 여러 포인터의
// 마지막 좌표를 독립적으로 추적한다. 한 손가락의 move 가 다른 손가락 좌표를 덮지 않는다.
@include stage($stage-ink);
.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.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: #17141a;
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.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 () {
// 기법: pointerdown 마다 e.pointerId 를 키로 하는 Map 에 마지막 좌표를 저장해
// 여러 포인터(멀티터치)가 서로를 덮어쓰지 않고 각자의 경로를 동시에 긁게 한다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var FW = foil.width, FH = foil.height;
var SCRATCH_BRUSH_R = 14;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var foilRand = seedRand(424051);
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, FW, FH);
var grad = ctx.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, '#5b6cff');
grad.addColorStop(.5, '#8f9bff');
grad.addColorStop(1, '#3c46c9');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FW, FH);
ctx.strokeStyle = 'rgba(23,20,26,.16)';
ctx.lineWidth = 1;
for (var i = 0; i < 18; i += 1) {
var x = foilRand() * FW, y = foilRand() * FH;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 20 - 10), y + (foilRand() * 8 - 4));
ctx.stroke();
}
}
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
}
function scratchLine(x0, y0, x1, y1) {
var dx = x1 - x0, dy = y1 - y0;
var dist = Math.max(1, Math.hypot(dx, dy));
var steps = Math.max(1, Math.ceil(dist / 6));
for (var i = 0; i <= steps; i += 1) {
scratchAt(x0 + dx * (i / steps), y0 + dy * (i / steps));
}
}
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 = '交換クーポンが開きました';
}
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 pointers = new Map(); // pointerId -> {x,y}
zone.addEventListener('pointerdown', function (e) {
if (opened) return;
root.classList.remove('is-demo');
var p = pos(e);
pointers.set(e.pointerId, p);
scratchAt(p.x, p.y);
if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (opened || !pointers.has(e.pointerId)) return;
var prev = pointers.get(e.pointerId);
var p = pos(e);
scratchLine(prev.x, prev.y, p.x, p.y);
pointers.set(e.pointerId, p);
if (clearedRatio() > .82) openCard();
});
function release(e) { pointers.delete(e.pointerId); }
window.addEventListener('pointerup', release);
window.addEventListener('pointercancel', release);
revealBtn.addEventListener('click', function () { root.classList.remove('is-demo'); openCard(); });
// 데모 경로 두 개를 서로 다른 pointerId(9001·9002)로 흉내내 동시 긁기를 재현한다
var demoRand = seedRand(424052);
function buildPath(baseX, baseY, count) {
var pts = [];
for (var i = 0; i < count; i += 1) {
pts.push({
t: 240 + i * 96,
x: baseX + (i % 4) * (FW * 0.12) + (demoRand() - .5) * 6,
y: baseY + Math.floor(i / 4) * (FH * 0.2) + (demoRand() - .5) * 6
});
}
return pts;
}
var pathA = buildPath(FW * 0.1, FH * 0.14, 12);
var pathB = buildPath(FW * 0.52, FH * 0.5, 12);
var DEMO_LOOP_MS = 4000;
var stepA = 0, stepB = 0;
var lastA = null, lastB = null;
function resetDemo() {
opened = false;
root.classList.remove('is-open');
paintFoil();
stepA = 0; stepB = 0; lastA = null; lastB = null;
}
function advance(path, stepRef, lastRef, tMod) {
var step = stepRef.v, last = lastRef.v;
while (step < path.length && path[step].t <= tMod) {
var p = path[step];
if (last) scratchLine(last.x, last.y, p.x, p.y); else scratchAt(p.x, p.y);
last = p;
step += 1;
}
stepRef.v = step; lastRef.v = last;
}
function runDemo(tMod) {
var sa = { v: stepA }, la = { v: lastA };
var sb = { v: stepB }, lb = { v: lastB };
advance(pathA, sa, la, tMod);
advance(pathB, sb, lb, tMod);
stepA = sa.v; lastA = la.v;
stepB = sb.v; lastB = lb.v;
if (clearedRatio() > .82) 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);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealMultitouchPointer.module.scss";
type Pt = { x: number; y: number };
export default function ScratchCardRevealMultitouchPointer() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = 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 revealBtn = revealBtnRef.current;
const live = liveRef.current;
if (!root || !zone || !foil || !revealBtn || !live) return;
const ctx = foil.getContext("2d");
if (!ctx) return;
const FW = foil.width, FH = foil.height;
const SCRATCH_BRUSH_R = 14;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const foilRand = seedRand(424051);
function paintFoil() {
ctx!.globalCompositeOperation = "source-over";
ctx!.clearRect(0, 0, FW, FH);
const grad = ctx!.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, "#5b6cff");
grad.addColorStop(.5, "#8f9bff");
grad.addColorStop(1, "#3c46c9");
ctx!.fillStyle = grad;
ctx!.fillRect(0, 0, FW, FH);
ctx!.strokeStyle = "rgba(23,20,26,.16)";
ctx!.lineWidth = 1;
for (let i = 0; i < 18; i += 1) {
const x = foilRand() * FW, y = foilRand() * FH;
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x + (foilRand() * 20 - 10), y + (foilRand() * 8 - 4));
ctx!.stroke();
}
}
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();
}
function scratchLine(x0: number, y0: number, x1: number, y1: number) {
const dx = x1 - x0, dy = y1 - y0;
const dist = Math.max(1, Math.hypot(dx, dy));
const steps = Math.max(1, Math.ceil(dist / 6));
for (let i = 0; i <= steps; i += 1) scratchAt(x0 + dx * (i / steps), y0 + dy * (i / steps));
}
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();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pointers = new Map<number, Pt>();
function onDown(e: PointerEvent) {
if (opened) return;
root!.classList.remove("is-demo");
const p = pos(e);
pointers.set(e.pointerId, p);
scratchAt(p.x, p.y);
zone!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (opened || !pointers.has(e.pointerId)) return;
const prev = pointers.get(e.pointerId)!;
const p = pos(e);
scratchLine(prev.x, prev.y, p.x, p.y);
pointers.set(e.pointerId, p);
if (clearedRatio() > .82) openCard();
}
function release(e: PointerEvent) { pointers.delete(e.pointerId); }
zone.addEventListener("pointerdown", onDown);
zone.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
function onReveal() { root!.classList.remove("is-demo"); openCard(); }
revealBtn.addEventListener("click", onReveal);
const demoRand = seedRand(424052);
type DemoPt = { t: number; x: number; y: number };
function buildPath(baseX: number, baseY: number, count: number) {
const pts: DemoPt[] = [];
for (let i = 0; i < count; i += 1) {
pts.push({
t: 240 + i * 96,
x: baseX + (i % 4) * (FW * 0.12) + (demoRand() - .5) * 6,
y: baseY + Math.floor(i / 4) * (FH * 0.2) + (demoRand() - .5) * 6
});
}
return pts;
}
const pathA = buildPath(FW * 0.1, FH * 0.14, 12);
const pathB = buildPath(FW * 0.52, FH * 0.5, 12);
const DEMO_LOOP_MS = 4000;
let stepA = 0, stepB = 0;
let lastA: DemoPt | null = null, lastB: DemoPt | null = null;
function resetDemo() {
opened = false;
root!.classList.remove("is-open");
paintFoil();
stepA = 0; stepB = 0; lastA = null; lastB = null;
}
function advance(path: DemoPt[], step: number, last: DemoPt | null, tMod: number): [number, DemoPt | null] {
while (step < path.length && path[step].t <= tMod) {
const p = path[step];
if (last) scratchLine(last.x, last.y, p.x, p.y); else scratchAt(p.x, p.y);
last = p;
step += 1;
}
return [step, last];
}
function runDemo(tMod: number) {
[stepA, lastA] = advance(pathA, stepA, lastA, tMod);
[stepB, lastB] = advance(pathB, stepB, lastB, tMod);
if (clearedRatio() > .82) 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);
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
zone.removeEventListener("pointerdown", onDown);
zone.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
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" />
</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>
);
}05 · 二本の指がそれぞれ消すマルチタッチスクラッチ — 計測 累積面積 17.315% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
if (opened) return;
root.classList.remove('is-demo');
var p = pos(e);
pointers.set(e.pointerId, p);
scratchAt(p.x, p.y);
if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (opened || !pointers.has(e.pointerId)) return;
var prev = pointers.get(e.pointerId);
var p = pos(e);
scratchLine(prev.x, prev.y, p.x, p.y);
pointers.set(e.pointerId, p);
if (clearedRatio() > .82) openCard();
});
function release(e) { pointers.delete(e.pointerId); }
window.addEventListener('pointerup', release);
05番はフレームの17.3%を塗り替え、平均強度121.7、測定した10/23 フレームで動きがありました。
06削った割合に合わせて満ちる進行リング
01番と同じ計算式で測ったスクラッチ済みピクセル比率がドーナツ型の進行リングを更新します — ただし毎フレームではありません。getImageData の全体スキャンは重く、約60fpsの毎ティックで呼ぶと描画パイプラインが詰まり、実測では17/23 フレーム目を過ぎたところで完全に停止しました。そのため tick() は実際にこすったティックか3ティックに1回だけ再計測します。

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__row">
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">ポイントクーポン</p>
<p class="scv__prizeSub">こすった分だけリングが満ちます</p>
</div>
<canvas class="scv__foil" id="foil" width="220" height="140" aria-hidden="true"></canvas>
</div>
<div class="scv__ringWrap">
<canvas class="scv__ring" id="ring" width="104" height="104" aria-hidden="true"></canvas>
<p class="scv__ringPct" id="ringPct">0%</p>
</div>
</div>
<button type="button" class="scv__reveal" id="revealBtn">一気に見る</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 06 — clearedPixelRatio() 는 01 항목과 동일한 getImageData 알파 카운트
// 계산식이다. 매 프레임 이 값을 진행 링(canvas arc)에도 그려 두 표시가 항상 같은 숫자를 공유한다.
@include stage($stage-yellow);
.scv { display: flex; justify-content: center; width: 100%; }
.scv__card {
width: min(100%, 360px); background: $subject-blue-deep; color: $subject-cream;
border-radius: $r-card; box-shadow: $shadow-raised; padding: $sp-3 $sp-4;
}
.scv__label { margin: 0 0 $sp-1; font-weight: 800; font-size: 12px; letter-spacing: .04em; }
.scv__row { display: flex; align-items: center; gap: $sp-3; }
.scv__zone {
position: relative; flex: 1 1 auto; border-radius: $r-control; overflow: hidden;
aspect-ratio: 220 / 140; 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(15px, 5vw, 19px); color: $subject-blue-deep; }
.scv__prizeSub { margin: 0; font-weight: 700; font-size: 10px; color: rgba(23, 20, 26, .68); }
.scv__foil { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; z-index: 1; }
.scv.is-open .scv__foil { visibility: hidden; }
.scv__ringWrap { position: relative; flex: 0 0 auto; width: 104px; height: 104px; display: grid; place-items: center; }
.scv__ring { width: 104px; height: 104px; }
.scv__ringPct { position: absolute; margin: 0; font-weight: 800; font-size: 15px; color: $subject-cream; }
.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: 0; min-height: 0; font-weight: 700; font-size: 10px; color: $subject-cream; }
@media (max-width: 340px) {
.scv__card { padding: $sp-3; }
.scv__row { flex-direction: column; }
.scv__zone { width: 100%; }
}
@media (max-height: 220px) {
.stage { padding: $sp-2; }
.scv__card { padding: $sp-2 $sp-3; }
.scv__label { margin: 0 0 $sp-1; }
.scv__row { flex-direction: row; gap: $sp-2; }
.scv__zone { width: auto; aspect-ratio: 220 / 68; }
.scv__ringWrap { width: 56px; height: 56px; }
.scv__ring { width: 56px; height: 56px; }
.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: #ffd23f;
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%, 360px);
background: #0a4fe8;
color: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px;
}
.scv__label {
margin: 0 0 4px;
font-weight: 800;
font-size: 12px;
letter-spacing: 0.04em;
}
.scv__row {
display: flex;
align-items: center;
gap: 12px;
}
.scv__zone {
position: relative;
flex: 1 1 auto;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 220/140;
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(15px, 5vw, 19px);
color: #0a4fe8;
}
.scv__prizeSub {
margin: 0;
font-weight: 700;
font-size: 10px;
color: rgba(23, 20, 26, 0.68);
}
.scv__foil {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: none;
z-index: 1;
}
.scv.is-open .scv__foil {
visibility: hidden;
}
.scv__ringWrap {
position: relative;
flex: 0 0 auto;
width: 104px;
height: 104px;
display: grid;
place-items: center;
}
.scv__ring {
width: 104px;
height: 104px;
}
.scv__ringPct {
position: absolute;
margin: 0;
font-weight: 800;
font-size: 15px;
color: #fff7e6;
}
.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: 0;
min-height: 0;
font-weight: 700;
font-size: 10px;
color: #fff7e6;
}
@media (max-width: 340px) {
.scv__card {
padding: 12px;
}
.scv__row {
flex-direction: column;
}
.scv__zone {
width: 100%;
}
}
@media (max-height: 220px) {
.stage {
padding: 8px;
}
.scv__card {
padding: 8px 12px;
}
.scv__label {
margin: 0 0 4px;
}
.scv__row {
flex-direction: row;
gap: 8px;
}
.scv__zone {
width: auto;
aspect-ratio: 220/68;
}
.scv__ringWrap {
width: 56px;
height: 56px;
}
.scv__ring {
width: 56px;
height: 56px;
}
.scv__reveal {
margin-top: 4px;
}
.scv__live {
margin-top: 4px;
min-height: 0.9em;
}
}
@media (prefers-reduced-motion: reduce) {
.scv {
cursor: default;
}
}(function () {
// 기법: clearedPixelRatio() 는 01 항목과 완전히 같은 getImageData 알파 카운트 계산식이다.
// 이 값을 매 프레임 진행 링(canvas arc)에도 그대로 반영해 두 시각 요소가 항상 같은 숫자를
// 공유하도록 한다 — 링은 별도 애니메이션이 아니라 같은 픽셀 계산의 또 다른 표현이다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var ring = document.getElementById('ring');
var rctx = ring.getContext('2d');
var ringPct = document.getElementById('ringPct');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var FW = foil.width, FH = foil.height;
var RING_SYNC_THRESHOLD_PCT = 92; // 시연 내내 링·카드가 계속 움직이도록 자동 오픈을 뒤로 미룬다
var SCRATCH_BRUSH_R = 12;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var foilRand = seedRand(424061);
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, FW, FH);
var grad = ctx.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, '#e8c344');
grad.addColorStop(.5, '#f6dd7c');
grad.addColorStop(1, '#c99f22');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FW, FH);
ctx.strokeStyle = 'rgba(23,20,26,.16)';
ctx.lineWidth = 1;
for (var i = 0; i < 16; i += 1) {
var x = foilRand() * FW, y = foilRand() * FH;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 18 - 9), y + (foilRand() * 8 - 4));
ctx.stroke();
}
}
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
}
function clearedPixelRatio() {
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 drawRing(ratio) {
var cx = ring.width / 2, cy = ring.height / 2, r = 42;
rctx.clearRect(0, 0, ring.width, ring.height);
rctx.lineWidth = 11;
rctx.strokeStyle = 'rgba(23,20,26,.18)';
rctx.beginPath();
rctx.arc(cx, cy, r, 0, Math.PI * 2);
rctx.stroke();
rctx.strokeStyle = '#0a4fe8';
rctx.lineCap = 'round';
rctx.beginPath();
rctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * ratio);
rctx.stroke();
ringPct.textContent = Math.round(ratio * 100) + '%';
}
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 = 'ポイントクーポンが開きました';
}
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();
drawRing(0);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
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);
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);
});
window.addEventListener('pointerup', function () { dragging = false; });
revealBtn.addEventListener('click', function () { root.classList.remove('is-demo'); openCard(); });
var demoRand = seedRand(424062);
// 격자를 미리 정해두고 순간이동하며 찍으면 캡처 간격(83ms)과 점 간격(58ms)이 어긋나
// 특정 프레임 구간이 통째로 비는 앨리어싱이 생긴다(실측: 12.8%·14/23 로 고정).
// 대신 실제 손가락 드래그처럼 매 rAF 틱마다 좌표를 연속으로 옮기며 긁어 — 캡처가
// 몇 ms 에 떨어지든 그 구간 안에 새로 지나간 궤적이 반드시 남는다.
var SWIPE_START = 180, SWIPE_DUR = 2200, SWIPE_ROWS = 7;
function swipePos(t) {
var f = Math.max(0, Math.min(1, (t - SWIPE_START) / SWIPE_DUR));
var rowF = f * SWIPE_ROWS;
var row = Math.min(SWIPE_ROWS - 1, Math.floor(rowF));
var frac = rowF - row;
var xFrac = (row % 2 === 0) ? frac : (1 - frac);
return {
x: FW * (0.08 + xFrac * 0.84) + (demoRand() - .5) * 3,
y: FH * (0.10 + (row / (SWIPE_ROWS - 1)) * 0.80) + (demoRand() - .5) * 3
};
}
var DEMO_LOOP_MS = 3600;
function resetDemo() {
opened = false;
root.classList.remove('is-open');
paintFoil();
}
function runDemo(tMod) {
if (tMod < SWIPE_START || tMod > SWIPE_START + SWIPE_DUR) return false;
var p = swipePos(tMod);
scratchAt(p.x, p.y);
return true;
}
if (reduce) root.classList.remove('is-demo');
var elapsed = 0, lastTs = null, ringTick = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
var scratched = false;
if (!reduce && root.classList.contains('is-demo')) {
elapsed += dt;
var tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) resetDemo();
scratched = runDemo(tMod);
}
// getImageData 전체 스캔은 무겁다 — 매 rAF 틱(약 60fps)마다 부르면 헤드리스 렌더 파이프라인이
// 밀려 일정 시점 뒤 rAF 자체가 멎는 현상이 실측됐다(23 프레임 중 17 부터 완전 정지).
// 실제로 긁은 틱과 3틱에 한 번(약 50ms, 출력 12fps 보다 촘촘)만 다시 재서 부하를 줄인다.
ringTick += 1;
if (scratched || ringTick % 3 === 0) {
var ratio = clearedPixelRatio();
drawRing(ratio);
// 시연 중엔 링·카드가 캡처 구간 내내 계속 움직여야 하므로 자동 오픈으로 멈추지 않는다 —
// 실제 사용자가 긁어서(is-demo 해제) 임계를 넘겼을 때만 카드가 열린다.
if (!root.classList.contains('is-demo') && ratio * 100 >= RING_SYNC_THRESHOLD_PCT) openCard();
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealProgressRingSync.module.scss";
export default function ScratchCardRevealProgressRingSync() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = useRef<HTMLCanvasElement>(null);
const ringRef = useRef<HTMLCanvasElement>(null);
const ringPctRef = useRef<HTMLParagraphElement>(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 ring = ringRef.current;
const ringPct = ringPctRef.current;
const revealBtn = revealBtnRef.current;
const live = liveRef.current;
if (!root || !zone || !foil || !ring || !ringPct || !revealBtn || !live) return;
const ctx = foil.getContext("2d");
const rctx = ring.getContext("2d");
if (!ctx || !rctx) return;
const FW = foil.width, FH = foil.height;
const RING_SYNC_THRESHOLD_PCT = 92; // 시연 내내 링·카드가 계속 움직이도록 자동 오픈을 뒤로 미룬다
const SCRATCH_BRUSH_R = 12;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const foilRand = seedRand(424061);
function paintFoil() {
ctx!.globalCompositeOperation = "source-over";
ctx!.clearRect(0, 0, FW, FH);
const grad = ctx!.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, "#e8c344");
grad.addColorStop(.5, "#f6dd7c");
grad.addColorStop(1, "#c99f22");
ctx!.fillStyle = grad;
ctx!.fillRect(0, 0, FW, FH);
ctx!.strokeStyle = "rgba(23,20,26,.16)";
ctx!.lineWidth = 1;
for (let i = 0; i < 16; i += 1) {
const x = foilRand() * FW, y = foilRand() * FH;
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x + (foilRand() * 18 - 9), y + (foilRand() * 8 - 4));
ctx!.stroke();
}
}
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();
}
function clearedPixelRatio() {
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 drawRing(ratio: number) {
const cx = ring!.width / 2, cy = ring!.height / 2, r = 42;
rctx!.clearRect(0, 0, ring!.width, ring!.height);
rctx!.lineWidth = 11;
rctx!.strokeStyle = "rgba(23,20,26,.18)";
rctx!.beginPath();
rctx!.arc(cx, cy, r, 0, Math.PI * 2);
rctx!.stroke();
rctx!.strokeStyle = "#0a4fe8";
rctx!.lineCap = "round";
rctx!.beginPath();
rctx!.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * ratio);
rctx!.stroke();
ringPct!.textContent = Math.round(ratio * 100) + "%";
}
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();
drawRing(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
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);
zone!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging || opened) return;
const p = pos(e);
scratchAt(p.x, p.y);
}
function onUp() { dragging = false; }
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(424062);
// 격자를 미리 정해두고 순간이동하며 찍으면 캡처 간격(83ms)과 점 간격이 어긋나
// 특정 프레임 구간이 통째로 비는 앨리어싱이 생긴다(실측: 12.8%·14/23 로 고정).
// 대신 실제 손가락 드래그처럼 매 rAF 틱마다 좌표를 연속으로 옮기며 긁는다.
const SWIPE_START = 180, SWIPE_DUR = 2200, SWIPE_ROWS = 7;
function swipePos(t: number) {
const f = Math.max(0, Math.min(1, (t - SWIPE_START) / SWIPE_DUR));
const rowF = f * SWIPE_ROWS;
const row = Math.min(SWIPE_ROWS - 1, Math.floor(rowF));
const frac = rowF - row;
const xFrac = (row % 2 === 0) ? frac : (1 - frac);
return {
x: FW * (0.08 + xFrac * 0.84) + (demoRand() - .5) * 3,
y: FH * (0.10 + (row / (SWIPE_ROWS - 1)) * 0.80) + (demoRand() - .5) * 3
};
}
const DEMO_LOOP_MS = 3600;
function resetDemo() {
opened = false;
root!.classList.remove("is-open");
paintFoil();
}
function runDemo(tMod: number) {
if (tMod < SWIPE_START || tMod > SWIPE_START + SWIPE_DUR) return false;
const p = swipePos(tMod);
scratchAt(p.x, p.y);
return true;
}
if (reduce) root.classList.remove("is-demo");
let elapsed = 0;
let lastTs: number | null = null;
let ringTick = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
let scratched = false;
if (!reduce && root!.classList.contains("is-demo")) {
elapsed += dt;
const tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) resetDemo();
scratched = runDemo(tMod);
}
// getImageData 전체 스캔은 무겁다 — 매 rAF 틱(약 60fps)마다 부르면 헤드리스 렌더
// 파이프라인이 밀려 일정 시점 뒤 rAF 자체가 멎는 현상이 실측됐다(23 프레임 중 17 부터 정지).
// 실제로 긁은 틱과 3틱에 한 번(약 50ms, 출력 12fps 보다 촘촘)만 다시 재서 부하를 줄인다.
ringTick += 1;
if (scratched || ringTick % 3 === 0) {
const ratio = clearedPixelRatio();
drawRing(ratio);
// 시연 중엔 링·카드가 캡처 구간 내내 계속 움직여야 하므로 자동 오픈으로 멈추지 않는다 —
// 실제 사용자가 긁어서(is-demo 해제) 임계를 넘겼을 때만 카드가 열린다.
if (!root!.classList.contains("is-demo") && ratio * 100 >= RING_SYNC_THRESHOLD_PCT) openCard();
}
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__row}>
<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={220} height={140} aria-hidden="true" />
</div>
<div className={styles.scv__ringWrap}>
<canvas className={styles.scv__ring} ref={ringRef} width={104} height={104} aria-hidden="true" />
<p className={styles.scv__ringPct} ref={ringPctRef}>0%</p>
</div>
</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>
);
}06 · 削った割合に合わせて満ちる進行リング — 計測 累積面積 24.568% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
var scratched = false;
if (!reduce && root.classList.contains('is-demo')) {
elapsed += dt;
var tMod = elapsed % DEMO_LOOP_MS;
if (tMod < dt) resetDemo();
scratched = runDemo(tMod);
}
// getImageDataの全体スキャンは重い — 毎rAFティック(約60fps)ごとに呼ぶとヘッドレス描画パイプラインが
// 詰まり、ある時点でrAF自体が止まる現象が実測された(17/23 フレームから完全停止)。
// 実際にこすったティックと3ティックに1回(約50ms、出力12fpsより細かい)だけ再計測して負荷を減らす。
ringTick += 1;
if (scratched || ringTick % 3 === 0) {
var ratio = clearedPixelRatio();
drawRing(ratio);
// デモ中はリング・カードがキャプチャ区間の間ずっと動き続ける必要があるため、自動オープンでは止まらない —
// 実際のユーザーがこすって(is-demo解除)閾値を超えたときだけカードが開く。
if (!root.classList.contains('is-demo') && ratio * 100 >= RING_SYNC_THRESHOLD_PCT) openCard();
}
requestAnimationFrame(tick);
06番はフレームの24.6%を塗り替え、平均強度133.3、測定した19/23 フレームで動きがありました。
07完全にめくれると弾ける紙吹雪
紙吹雪は burst フラグで一度だけに制限され、消した比率が88%を超えた瞬間に発生します。そこから stepConfetti() が毎ティック CONFETTI_GRAVITY を各破片の縦速度に加算し、床を越えて着地した破片は -CONFETTI_BOUNCE 倍で跳ね返り、3回跳ねたところで止まります。

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__zone" id="zone">
<div class="scv__prize">
<p class="scv__prizeTitle">お祝いクーポン</p>
<p class="scv__prizeSub">ほとんどこすり終えると紙吹雪が弾けます</p>
</div>
<canvas class="scv__foil" id="foil" width="280" height="150" aria-hidden="true"></canvas>
<canvas class="scv__confetti" id="confetti" width="280" height="150" aria-hidden="true"></canvas>
</div>
<button type="button" class="scv__reveal" id="revealBtn">一気に見る</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 07 — 긁은 비율이 88% 를 넘으면 burstConfetti() 가 CONFETTI_COUNT 개를
// 한 번에 만들고 stepConfetti() 가 중력·바닥 반발(bounce)을 매 프레임 적분한다.
@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__confetti { 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__confetti {
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 () {
// 기법: 긁은 비율이 88% 를 넘는 순간 CONFETTI_COUNT 개의 색종이를 한 번에 만들어
// 중력으로 낙하시키고 카드 바닥에 닿으면 반발계수만큼 튕기게(bounce) 물리 적분한다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var confettiCanvas = document.getElementById('confetti');
var cctx = confettiCanvas.getContext('2d');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var FW = foil.width, FH = foil.height;
var CONFETTI_COUNT = 24;
var CONFETTI_GRAVITY = 620;
var CONFETTI_BOUNCE = 0.42;
var SCRATCH_BRUSH_R = 15;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var foilRand = seedRand(424071);
var confettiRand = seedRand(424072);
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, FW, FH);
var grad = ctx.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, '#ff8a5c');
grad.addColorStop(.5, '#ffb488');
grad.addColorStop(1, '#e85f2a');
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();
}
}
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
}
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 COLORS = ['#0a4fe8', '#fff7e6', '#ff4d1f', '#ffd23f'];
var confetti = [];
function burstConfetti() {
confetti = [];
for (var i = 0; i < CONFETTI_COUNT; i += 1) {
confetti.push({
x: FW * (0.3 + confettiRand() * 0.4),
y: FH * 0.3,
vx: (confettiRand() - .5) * 220,
vy: -180 - confettiRand() * 140,
rot: confettiRand() * Math.PI * 2,
angVel: (confettiRand() - .5) * 8,
w: 5 + confettiRand() * 3,
h: 3 + confettiRand() * 2,
color: COLORS[i % COLORS.length],
bounces: 0
});
}
}
function stepConfetti(dtMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < confetti.length; i += 1) {
var c = confetti[i];
c.vy += CONFETTI_GRAVITY * dtS;
c.x += c.vx * dtS;
c.y += c.vy * dtS;
c.rot += c.angVel * dtS;
if (c.y > FH - 6 && c.vy > 0 && c.bounces < 3) {
c.y = FH - 6;
c.vy *= -CONFETTI_BOUNCE;
c.vx *= 0.7;
c.bounces += 1;
}
}
}
function drawConfetti() {
cctx.clearRect(0, 0, FW, FH);
for (var i = 0; i < confetti.length; i += 1) {
var c = confetti[i];
cctx.save();
cctx.translate(c.x, c.y);
cctx.rotate(c.rot);
cctx.fillStyle = c.color;
cctx.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
cctx.restore();
}
}
var opened = false, burst = 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 maybeBurst(ratio) {
if (!burst && ratio > .88) { burst = true; burstConfetti(); }
if (ratio > .96) 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 reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
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);
maybeBurst(clearedRatio());
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);
maybeBurst(clearedRatio());
});
window.addEventListener('pointerup', function () { dragging = false; });
revealBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!burst) { burst = true; burstConfetti(); }
openCard();
});
var demoRand = seedRand(424073);
var demoPath = [];
(function buildDemoPath() {
for (var i = 0; i < 24; i += 1) {
demoPath.push({
t: 220 + i * 78,
x: (FW * 0.12) + (i % 6) * (FW * 0.15) + (demoRand() - .5) * 6,
y: (FH * 0.22) + Math.floor(i / 6) * (FH * 0.24) + (demoRand() - .5) * 6
});
}
})();
var DEMO_LOOP_MS = 4200;
var demoStep = 0;
function resetDemo() {
opened = false; burst = false;
root.classList.remove('is-open');
paintFoil();
confetti = [];
demoStep = 0;
}
function runDemo(tMod) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
var p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
maybeBurst(clearedRatio());
}
}
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) stepConfetti(dt);
drawConfetti();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealPrizeConfettiBurst.module.scss";
type Confetto = {
x: number; y: number; vx: number; vy: number; rot: number; angVel: number;
w: number; h: number; color: string; bounces: number;
};
export default function ScratchCardRevealPrizeConfettiBurst() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = useRef<HTMLCanvasElement>(null);
const confettiRef = 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 confettiCanvas = confettiRef.current;
const revealBtn = revealBtnRef.current;
const live = liveRef.current;
if (!root || !zone || !foil || !confettiCanvas || !revealBtn || !live) return;
const ctx = foil.getContext("2d");
const cctx = confettiCanvas.getContext("2d");
if (!ctx || !cctx) return;
const FW = foil.width, FH = foil.height;
const CONFETTI_COUNT = 24;
const CONFETTI_GRAVITY = 620;
const CONFETTI_BOUNCE = 0.42;
const SCRATCH_BRUSH_R = 15;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const foilRand = seedRand(424071);
const confettiRand = seedRand(424072);
function paintFoil() {
ctx!.globalCompositeOperation = "source-over";
ctx!.clearRect(0, 0, FW, FH);
const grad = ctx!.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, "#ff8a5c");
grad.addColorStop(.5, "#ffb488");
grad.addColorStop(1, "#e85f2a");
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();
}
}
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();
}
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;
}
const COLORS = ["#0a4fe8", "#fff7e6", "#ff4d1f", "#ffd23f"];
let confetti: Confetto[] = [];
function burstConfetti() {
confetti = [];
for (let i = 0; i < CONFETTI_COUNT; i += 1) {
confetti.push({
x: FW * (0.3 + confettiRand() * 0.4),
y: FH * 0.3,
vx: (confettiRand() - .5) * 220,
vy: -180 - confettiRand() * 140,
rot: confettiRand() * Math.PI * 2,
angVel: (confettiRand() - .5) * 8,
w: 5 + confettiRand() * 3,
h: 3 + confettiRand() * 2,
color: COLORS[i % COLORS.length],
bounces: 0
});
}
}
function stepConfetti(dtMs: number) {
const dtS = dtMs / 1000;
for (let i = 0; i < confetti.length; i += 1) {
const c = confetti[i];
c.vy += CONFETTI_GRAVITY * dtS;
c.x += c.vx * dtS;
c.y += c.vy * dtS;
c.rot += c.angVel * dtS;
if (c.y > FH - 6 && c.vy > 0 && c.bounces < 3) {
c.y = FH - 6;
c.vy *= -CONFETTI_BOUNCE;
c.vx *= 0.7;
c.bounces += 1;
}
}
}
function drawConfetti() {
cctx!.clearRect(0, 0, FW, FH);
for (let i = 0; i < confetti.length; i += 1) {
const c = confetti[i];
cctx!.save();
cctx!.translate(c.x, c.y);
cctx!.rotate(c.rot);
cctx!.fillStyle = c.color;
cctx!.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
cctx!.restore();
}
}
let opened = false, burst = 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 maybeBurst(ratio: number) {
if (!burst && ratio > .88) { burst = true; burstConfetti(); }
if (ratio > .96) 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();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
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);
maybeBurst(clearedRatio());
zone!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging || opened) return;
const p = pos(e);
scratchAt(p.x, p.y);
maybeBurst(clearedRatio());
}
function onUp() { dragging = false; }
zone.addEventListener("pointerdown", onDown);
zone.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
function onReveal() {
root!.classList.remove("is-demo");
if (!burst) { burst = true; burstConfetti(); }
openCard();
}
revealBtn.addEventListener("click", onReveal);
const demoRand = seedRand(424073);
type DemoPt = { t: number; x: number; y: number };
const demoPath: DemoPt[] = [];
for (let i = 0; i < 24; i += 1) {
demoPath.push({
t: 220 + i * 78,
x: (FW * 0.12) + (i % 6) * (FW * 0.15) + (demoRand() - .5) * 6,
y: (FH * 0.22) + Math.floor(i / 6) * (FH * 0.24) + (demoRand() - .5) * 6
});
}
const DEMO_LOOP_MS = 4200;
let demoStep = 0;
function resetDemo() {
opened = false; burst = false;
root!.classList.remove("is-open");
paintFoil();
confetti = [];
demoStep = 0;
}
function runDemo(tMod: number) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
const p = demoPath[demoStep];
scratchAt(p.x, p.y);
demoStep += 1;
maybeBurst(clearedRatio());
}
}
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) stepConfetti(dt);
drawConfetti();
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__confetti} ref={confettiRef} 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>
);
}07 · 完全にめくれると弾ける紙吹雪 — 計測 累積面積 14.252% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
vy: -180 - confettiRand() * 140,
rot: confettiRand() * Math.PI * 2,
angVel: (confettiRand() - .5) * 8,
w: 5 + confettiRand() * 3,
h: 3 + confettiRand() * 2,
color: COLORS[i % COLORS.length],
bounces: 0
});
}
}
function stepConfetti(dtMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < confetti.length; i += 1) {
var c = confetti[i];
c.vy += CONFETTI_GRAVITY * dtS;
c.x += c.vx * dtS;
c.y += c.vy * dtS;
c.rot += c.angVel * dtS;
if (c.y > FH - 6 && c.vy > 0 && c.bounces < 3) {
c.y = FH - 6;
c.vy *= -CONFETTI_BOUNCE;
c.vx *= 0.7;
c.bounces += 1;
}
}
07番はフレームの14.3%を塗り替え、平均強度136.4、測定した16/23 フレームで動きがありました。
08矢印キーでも同じ結果を開く代替経路
マウス専用操作の埋め合わせとしての空のハンドラーはここにはありません。keydown は矢印キーで cursor.x・cursor.y を KEY_STEP ずつ動かし、Enter か Space はポインターでドラッグしたときとまったく同じ scratchAt() と clearedRatio() の経路を呼んでから、同じ82%の閾値でカードを開きます。

<main class="stage">
<div class="scv is-demo" id="scv">
<div class="scv__card">
<p class="scv__label">スクラッチカード</p>
<div class="scv__zone" id="zone" tabindex="0" role="application" aria-label="スクラッチカード、方向キーで移動してからスペースでこする">
<div class="scv__prize">
<p class="scv__prizeTitle">送料クーポン</p>
<p class="scv__prizeSub">方向キー+スペースでもこすれます</p>
</div>
<canvas class="scv__foil" id="foil" width="280" height="150" aria-hidden="true"></canvas>
<div class="scv__cursor" id="cursor" aria-hidden="true"></div>
</div>
<button type="button" class="scv__reveal" id="revealBtn">一気に見る</button>
<p class="scv__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 카드 08 — 키보드 커서(cursor.x/y)와 포인터가 같은 scratchAt()/clearedRatio() 를
// 호출한다. 방향키로 이동(state 변화) + Enter/Space 로 긁기, 전부 preventDefault 처리.
@include stage($stage-paper);
.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: #eef0f3; touch-action: none;
}
.scv__zone:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.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%; z-index: 1; }
.scv.is-open .scv__foil { visibility: hidden; }
.scv__cursor {
position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px;
border: 2px solid $stage-ink; border-radius: 50%; z-index: 2; pointer-events: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, .8);
}
.scv.is-open .scv__cursor { display: none; }
.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-ink; 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: #ffffff;
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: #eef0f3;
touch-action: none;
}
.scv__zone:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.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%;
z-index: 1;
}
.scv.is-open .scv__foil {
visibility: hidden;
}
.scv__cursor {
position: absolute;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border: 2px solid #17141a;
border-radius: 50%;
z-index: 2;
pointer-events: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8);
}
.scv.is-open .scv__cursor {
display: none;
}
.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 #17141a;
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 () {
// 기법: 키보드로 이동하는 가상 커서(cursorX, cursorY)를 두고 Enter/Space 가 포인터 드래그와
// 완전히 같은 scratchAt()/clearedRatio() 경로를 호출한다 — 별도의 "키보드용 처리"가 없다.
var root = document.getElementById('scv');
var zone = document.getElementById('zone');
var foil = document.getElementById('foil');
var ctx = foil.getContext('2d');
var cursorEl = document.getElementById('cursor');
var revealBtn = document.getElementById('revealBtn');
var live = document.getElementById('live');
var FW = foil.width, FH = foil.height;
var SCRATCH_BRUSH_R = 16;
var KEY_STEP = 22;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var foilRand = seedRand(424081);
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, FW, FH);
var grad = ctx.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, '#dfe3ea');
grad.addColorStop(.5, '#f4f6f9');
grad.addColorStop(1, '#c9ced8');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FW, FH);
ctx.strokeStyle = 'rgba(23,20,26,.14)';
ctx.lineWidth = 1;
for (var i = 0; i < 18; i += 1) {
var x = foilRand() * FW, y = foilRand() * FH;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (foilRand() * 20 - 10), y + (foilRand() * 8 - 4));
ctx.stroke();
}
}
// 실제 긁기 경로 — 포인터도 키보드도 이 함수 하나만 호출한다
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
ctx.fill();
}
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 = '送料クーポンが開きました';
}
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;
var cursor = { x: FW / 2, y: FH / 2 };
function placeCursor() {
var r = foil.getBoundingClientRect();
var scaleX = r.width / FW, scaleY = r.height / FH;
cursorEl.style.left = (cursor.x * scaleX) + 'px';
cursorEl.style.top = (cursor.y * scaleY) + 'px';
}
zone.addEventListener('pointerdown', function (e) {
if (opened) return;
root.classList.remove('is-demo');
dragging = true;
var p = pos(e);
cursor.x = p.x; cursor.y = p.y;
scratchAt(p.x, p.y);
placeCursor();
if (clearedRatio() > .82) openCard();
if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', function (e) {
if (!dragging || opened) return;
var p = pos(e);
cursor.x = p.x; cursor.y = p.y;
scratchAt(p.x, p.y);
placeCursor();
if (clearedRatio() > .82) openCard();
});
window.addEventListener('pointerup', function () { dragging = false; });
// 키보드 경로 — 빈 핸들러 없음. 실제로 커서를 옮기고(state 변화) preventDefault 로 스크롤을 막는다.
zone.addEventListener('keydown', function (e) {
if (opened) return;
var moved = false;
if (e.key === 'ArrowUp') { cursor.y = Math.max(6, cursor.y - KEY_STEP); moved = true; }
else if (e.key === 'ArrowDown') { cursor.y = Math.min(FH - 6, cursor.y + KEY_STEP); moved = true; }
else if (e.key === 'ArrowLeft') { cursor.x = Math.max(6, cursor.x - KEY_STEP); moved = true; }
else if (e.key === 'ArrowRight') { cursor.x = Math.min(FW - 6, cursor.x + KEY_STEP); moved = true; }
else if (e.key === 'Enter' || e.key === ' ') {
root.classList.remove('is-demo');
scratchAt(cursor.x, cursor.y);
var ratio = clearedRatio();
live.textContent = Math.round(ratio * 100) + '%こすりました';
if (ratio > .82) openCard();
e.preventDefault();
return;
} else { return; }
root.classList.remove('is-demo');
placeCursor();
e.preventDefault();
});
revealBtn.addEventListener('click', function () { root.classList.remove('is-demo'); openCard(); });
// 자동 재생도 같은 scratchAt() 를 호출 — 키보드 시뮬레이션과 같은 경로
var demoRand = seedRand(424082);
var demoPath = [];
(function buildDemoPath() {
for (var i = 0; i < 20; i += 1) {
demoPath.push({
t: 220 + i * 90,
x: (FW * 0.14) + (i % 5) * (FW * 0.18) + (demoRand() - .5) * 6,
y: (FH * 0.22) + Math.floor(i / 5) * (FH * 0.28) + (demoRand() - .5) * 6
});
}
})();
var DEMO_LOOP_MS = 3800;
var demoStep = 0;
function resetDemo() {
opened = false;
root.classList.remove('is-open');
paintFoil();
demoStep = 0;
}
function runDemo(tMod) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
var p = demoPath[demoStep];
cursor.x = p.x; cursor.y = p.y;
scratchAt(p.x, p.y);
placeCursor();
demoStep += 1;
if (clearedRatio() > .82) openCard();
}
}
placeCursor();
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);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./ScratchCardRevealKeyboardFallback.module.scss";
export default function ScratchCardRevealKeyboardFallback() {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const foilRef = useRef<HTMLCanvasElement>(null);
const cursorRef = useRef<HTMLDivElement>(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 cursorEl = cursorRef.current;
const revealBtn = revealBtnRef.current;
const live = liveRef.current;
if (!root || !zone || !foil || !cursorEl || !revealBtn || !live) return;
const ctx = foil.getContext("2d");
if (!ctx) return;
const FW = foil.width, FH = foil.height;
const SCRATCH_BRUSH_R = 16;
const KEY_STEP = 22;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const foilRand = seedRand(424081);
function paintFoil() {
ctx!.globalCompositeOperation = "source-over";
ctx!.clearRect(0, 0, FW, FH);
const grad = ctx!.createLinearGradient(0, 0, FW, FH);
grad.addColorStop(0, "#dfe3ea");
grad.addColorStop(.5, "#f4f6f9");
grad.addColorStop(1, "#c9ced8");
ctx!.fillStyle = grad;
ctx!.fillRect(0, 0, FW, FH);
ctx!.strokeStyle = "rgba(23,20,26,.14)";
ctx!.lineWidth = 1;
for (let i = 0; i < 18; i += 1) {
const x = foilRand() * FW, y = foilRand() * FH;
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x + (foilRand() * 20 - 10), y + (foilRand() * 8 - 4));
ctx!.stroke();
}
}
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();
}
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();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let dragging = false;
const cursor = { x: FW / 2, y: FH / 2 };
function placeCursor() {
const r = foil!.getBoundingClientRect();
const scaleX = r.width / FW, scaleY = r.height / FH;
cursorEl!.style.left = (cursor.x * scaleX) + "px";
cursorEl!.style.top = (cursor.y * scaleY) + "px";
}
function onDown(e: PointerEvent) {
if (opened) return;
root!.classList.remove("is-demo");
dragging = true;
const p = pos(e);
cursor.x = p.x; cursor.y = p.y;
scratchAt(p.x, p.y);
placeCursor();
if (clearedRatio() > .82) openCard();
zone!.setPointerCapture?.(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging || opened) return;
const p = pos(e);
cursor.x = p.x; cursor.y = p.y;
scratchAt(p.x, p.y);
placeCursor();
if (clearedRatio() > .82) openCard();
}
function onUp() { dragging = false; }
zone.addEventListener("pointerdown", onDown);
zone.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
function onKeydown(e: KeyboardEvent) {
if (opened) return;
let moved = false;
if (e.key === "ArrowUp") { cursor.y = Math.max(6, cursor.y - KEY_STEP); moved = true; }
else if (e.key === "ArrowDown") { cursor.y = Math.min(FH - 6, cursor.y + KEY_STEP); moved = true; }
else if (e.key === "ArrowLeft") { cursor.x = Math.max(6, cursor.x - KEY_STEP); moved = true; }
else if (e.key === "ArrowRight") { cursor.x = Math.min(FW - 6, cursor.x + KEY_STEP); moved = true; }
else if (e.key === "Enter" || e.key === " ") {
root!.classList.remove("is-demo");
scratchAt(cursor.x, cursor.y);
const ratio = clearedRatio();
live!.textContent = Math.round(ratio * 100) + "% 긁었어요";
if (ratio > .82) openCard();
e.preventDefault();
return;
} else { return; }
if (!moved) return;
root!.classList.remove("is-demo");
placeCursor();
e.preventDefault();
}
zone.addEventListener("keydown", onKeydown);
function onReveal() { root!.classList.remove("is-demo"); openCard(); }
revealBtn.addEventListener("click", onReveal);
const demoRand = seedRand(424082);
type DemoPt = { t: number; x: number; y: number };
const demoPath: DemoPt[] = [];
for (let i = 0; i < 20; i += 1) {
demoPath.push({
t: 220 + i * 90,
x: (FW * 0.14) + (i % 5) * (FW * 0.18) + (demoRand() - .5) * 6,
y: (FH * 0.22) + Math.floor(i / 5) * (FH * 0.28) + (demoRand() - .5) * 6
});
}
const DEMO_LOOP_MS = 3800;
let demoStep = 0;
function resetDemo() {
opened = false;
root!.classList.remove("is-open");
paintFoil();
demoStep = 0;
}
function runDemo(tMod: number) {
while (demoStep < demoPath.length && demoPath[demoStep].t <= tMod) {
const p = demoPath[demoStep];
cursor.x = p.x; cursor.y = p.y;
scratchAt(p.x, p.y);
placeCursor();
demoStep += 1;
if (clearedRatio() > .82) openCard();
}
}
placeCursor();
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);
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
zone.removeEventListener("pointerdown", onDown);
zone.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
zone.removeEventListener("keydown", onKeydown);
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}
tabIndex={0}
role="application"
aria-label="스크래치 카드, 방향키로 이동 후 스페이스로 긁기"
>
<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" />
<div className={styles.scv__cursor} ref={cursorRef} 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>
);
}08 · 矢印キーでも同じ結果を開く代替経路 — 計測 累積面積 4.137% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// キーボード経路 — 空のハンドラーなし。実際にカーソルを動かし(状態変化)、preventDefaultでスクロールを防ぐ。
zone.addEventListener('keydown', function (e) {
if (opened) return;
var moved = false;
if (e.key === 'ArrowUp') { cursor.y = Math.max(6, cursor.y - KEY_STEP); moved = true; }
else if (e.key === 'ArrowDown') { cursor.y = Math.min(FH - 6, cursor.y + KEY_STEP); moved = true; }
else if (e.key === 'ArrowLeft') { cursor.x = Math.max(6, cursor.x - KEY_STEP); moved = true; }
else if (e.key === 'ArrowRight') { cursor.x = Math.min(FW - 6, cursor.x + KEY_STEP); moved = true; }
else if (e.key === 'Enter' || e.key === ' ') {
root.classList.remove('is-demo');
scratchAt(cursor.x, cursor.y);
var ratio = clearedRatio();
live.textContent = Math.round(ratio * 100) + '%こすりました';
if (ratio > .82) openCard();
e.preventDefault();
return;
} else { return; }
root.classList.remove('is-demo');
placeCursor();
e.preventDefault();
});
08番はフレームの4.1%を塗り替え、平均強度84.9、測定した15/23 フレームで動きがありました。
09複数枚を一画面に並べたヒーロー場面
makeCard() が複数枚のカードを配置し、それぞれが01番のピクセル計算と03番の破片物理を独立して走らせます。カードの比率が HERO_REVEAL_THRESHOLD_PCT を超えると burstConfetti() が07番の重力・跳ね返りのステップを共有の stepParticles() ループで場面全体に再利用します。

<main class="stage">
<div class="scv is-demo" id="scv">
<p class="scv__label">スクラッチカード3枚セット</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">割引クーポン</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">送料無料</p><p class="scv__prizeSub">クーポン</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">ポイント</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">全部一気に見る</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 = ['10%割引クーポンが開きました', '送料無料クーポンが開きました', 'ポイント2,000ウォンクーポンが開きました'];
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 · 複数枚を一画面に並べたヒーロー場面 — 計測 累積面積 15.311% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
09番はフレームの15.3%を塗り替え、平均強度107.0、測定した17/23 フレームで動きがありました。
スクラッチカード9本の面積と強度をまとめる
すべてのデモを計測した24フレームのキャプチャ区間は、9本を横並びで比べる共通のものさしにもなります — 累積で塗り替えた面積・単一フレームでの最大面積・動いたピクセルの平均強度、そして測定した23個の差分のうち実際に動きがあったフレーム数です。
| # | パターン | 面積 | 最大面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|---|
| 01 | ピクセル閾値の自動めくれ | 12.1% | 1.2% | 40.4 | 15/23 |
| 02 | 箔ノイズテクスチャ | 28.6% | 9.5% | 37.2 | 17/23 |
| 03 | 破片重力パーティクル | 12.3% | 1.7% | 82.1 | 21/23 |
| 04 | 円形ワイプlerp | 23.4% | 19.4% | 69.0 | 23/23 |
| 05 | マルチタッチポインター | 17.3% | 4.9% | 121.7 | 10/23 |
| 06 | 進行リング同期 | 24.6% | 1.8% | 133.3 | 19/23 |
| 07 | 紙吹雪の爆発 | 14.3% | 1.2% | 136.4 | 16/23 |
| 08 | キーボード代替経路 | 4.1% | 0.8% | 84.9 | 15/23 |
| 09 | 複数カードのヒーロー場面 | 15.3% | 2.1% | 107.0 | 17/23 |
08番が9本の中で最も面積が小さいのは計算式が違うからではありません — 01・04・05番と同じ clearedRatio() を共有しています。理由はキー入力のたびに固定の KEY_STEP だけカーソルが動くためで、自由なドラッグより同じキャプチャ区間で動くピクセルが少なくなるからです。
値がリビール判定を変える場所 — めくれ閾値・破片の重力加速度・紙吹雪の数
| 変形 | 値 | 感触 |
|---|---|---|
| めくれ閾値を調整する | 01 SCRATCH_REVEAL_THRESHOLD_PCT 50 → 80 |
値を下げると少し削るだけで残りがすぐ自動でめくれ、値を上げるとかなり多く削らないと自動めくれが起きない。 |
| 破片の重力加速度を調整する | 03 FLAKE_GRAVITY_PX_S2 400 → 1200 |
値を小さくすると破片はゆっくり舞って長く漂い、値を大きくすると素早く落ちて一瞬で消える。 |
| 紙吹雪の数を調整する | 07 CONFETTI_COUNT 24 → 60 |
値を小さくすると紙吹雪はまばらに数個だけ跳ね、値を大きくすると画面を埋めるほど密に弾ける。 |
この3つの値はどれもレイアウトや重力計算そのものには触れません — 同じ計算がどこで閾値を超えるかを動かすだけなので、上の表は作り直しではなく感触の変化として読めます。
どこで壊れるか — 動作を減らす設定・連続する高速スクラッチ・低スペック端末でのフレーム低下
9本すべてが起動時に一度だけ prefers-reduced-motion を確認し、同じ方法で自身のアイドルデモループを止めます(if (reduce) root.classList.remove('is-demo') と runDemo() の前の !reduce ガード)。03・07・09番はさらに一歩進んで、同じフラグの下で stepFlakes()・stepConfetti()・stepParticles() 自体も止めるため、自動再生しないだけでなく破片そのものが一つも発生しません。
9本の差がいちばん出るのは連続する高速スクラッチです。01・04・05・08番はポインターやキーのハンドラーの中でスロットルなしに毎回 clearedRatio() を呼びます — マウスのイベント頻度なら問題ありませんが、05番で二本指の速い動きを再現すると独立した pointermove ストリームが二つ走り、それぞれがキャンバス全体の getImageData スキャンを毎回実行します。06番だけがこの呼び出しを意図的にスロットルしており、スロットルなしの版が requestAnimationFrame を17/23 フレーム目を過ぎたところで止めていたと実測されたからです — その事実は上の06番のコード内コメントにそのまま残っています。低スペック端末では、破片や紙吹雪の物理計算が重くなるより先に、この毎回の getImageData コストが最初にフレームを落とす箇所になります。キャンバス全体のピクセルスキャンとフレーム予算の同じ綱引きは、別の9本を扱うhtml5 canvas アニメーション9種 — 水滴とブラックホールにも出てきます。
27qe6aea は9本の index.html・style.scss の組の中にあり、並べて読むことが06番だけスロットルが必要だった理由を確かめる一番早い方法です。
変形3つとアクセシビリティ
上の変形はどれも SCRATCH_REVEAL_THRESHOLD_PCT・FLAKE_GRAVITY_PX_S2・CONFETTI_COUNT という名前の付いた定数を一つだけ書き換え、そのパターンの他の行は一切触れません。つまりこの表は、スクラッチカードゲームのピクセル計算そのものを崩さずに感触だけを変える場所を示す地図にもなっています。
08番のキーボード経路は名ばかりの代替ではなく本物のキーボード対応です。矢印キーが目に見えるカーソルを動かし、Enter・Space はポインター操作のどの項目とも同じ clearedRatio() の呼び出し経路でその位置をこすり、各キー操作の e.preventDefault() がページのスクロールを止めます。上で説明した動作を減らす設定の挙動については、MDNの prefers-reduced-motion リファレンスとMDNのCanvas getImageData() リファレンスが、この9本が最も頼っているブラウザAPIを2つとも解説しています。
スクラッチカードとクーポンデザインは何が違うのか?
クーポン デザイン 9選 — スタンプからルーレットまではクーポンの静止した見た目についての編です — スタンプ・チケットの縁・回転するルーレットを主にCSSで組み、クラスが切り替わった瞬間に完成します。スクラッチカード 作り方という観点では、この9本はその逆です。クラスの切り替えでカードの見た目が変わることはありません。「リビール」はキャンバスのピクセル数が閾値を超える瞬間そのものを、1ピクセルずつリアルタイムで数えているからです。プロジェクトがクーポンをある見た目で「活性化した瞬間」だけ見せたいならクーポン編を、こすり取るという操作そのものが目的ならこの9パターンが向いています。
よくある質問 — スクラッチカードゲームを試す前に
この9パターンはビルド手順なしでそのままページに置けますか?
はい。9本それぞれが単一の index.html と style.scss だけで完結し、9つの <script> タグのどこにも外部ライブラリやCDNスクリプトはありません。Sassをコンパイルしてマークアップを貼り付けるだけです。
物理演算ライブラリを追加しなくても、実際のプロモーションに使って安全ですか?
9本すべての重力・跳ね返り・lerpの計算は requestAnimationFrame のティックの中の単純な算術です。9つのファイルのどこにも Math.random() はなく、シード付きの seedRand() だけが使われています。シードを固定すればリロードをまたいでも同じカードを再現できます。
06番はなぜ毎フレームではなく3ティックに1回しか進行リングを再計算しないのですか?
getImageData() がキャンバス全体のピクセルを走査するからです。06番自身のコード内コメントに、スロットルなしの版が17/23 フレーム目を過ぎたところで requestAnimationFrame を止めていたと記録されています。上のコードブロック内のスロットルは、リングのアニメーション自体を生かし続けるためにあります。








