04スクラッチクーポン
コインでこすると割引率が現れるスクラッチクーポン。銀箔はcanvasに塗り、destination-outで本当に削れ、半分以上剥がれると残りが自分のはがれていきます。
使いどころランダム報酬クーポン、キャンペーン応募結果カード
globalCompositeOperationdestination-outpointermove

<main class="stage">
<div class="sc is-demo" id="sc">
<div class="sc__card">
<div class="sc__head">
<p class="sc__title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="sc__ico" aria-hidden="true"><path d="M232,104c0,24-40,48-104,48S24,128,24,104,64,56,128,56,232,80,232,104Z" opacity="0.2"/><path d="M207.58,63.84C186.85,53.48,159.33,48,128,48S69.15,53.48,48.42,63.84,16,88.78,16,104v48c0,15.22,11.82,29.85,32.42,40.16S96.67,208,128,208s58.85-5.48,79.58-15.84S240,167.22,240,152V104C240,88.78,228.18,74.15,207.58,63.84ZM128,64c62.64,0,96,23.23,96,40s-33.36,40-96,40-96-23.23-96-40S65.36,64,128,64Zm-8,95.86v32c-19-.62-35-3.42-48-7.49V153.05A203.43,203.43,0,0,0,120,159.86Zm16,0a203.43,203.43,0,0,0,48-6.81v31.31c-13,4.07-29,6.87-48,7.49ZM32,152V133.53a82.88,82.88,0,0,0,16.42,10.63c2.43,1.21,5,2.35,7.58,3.43V178C40.17,170.16,32,160.29,32,152Zm168,26V147.59c2.61-1.08,5.15-2.22,7.58-3.43A82.88,82.88,0,0,0,224,133.53V152C224,160.29,215.83,170.16,200,178Z"/></svg><span>こすって見て</span></p>
<button type="button" class="sc__reveal" id="revealBtn">すぐ見る</button>
</div>
<div class="sc__zone" id="zone">
<div class="sc__prize">
<p class="sc__pct">30<i>%</i></p>
<p class="sc__say">隠された割引</p>
</div>
<canvas class="sc__foil" id="foil" width="240" height="88" aria-hidden="true"></canvas>
<div class="sc__demoFoil" aria-hidden="true"><span class="sc__demoText">銀箔をこすってください</span></div>
<div class="sc__remnant" aria-hidden="true"></div>
<div class="sc__coin" aria-hidden="true"></div>
</div>
<p class="sc__live" id="live" aria-live="polite"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크래치 쿠폰 — 실제 긁기는 canvas 의 destination-out(포인터를 따라 은박을 지운다).
// 격자 미리보기(.is-demo)는 동전이 지나간 자리를 clip-path 로 wipe 해 긁히는 모양을 재현한다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.sc { display: flex; justify-content: center; width: 100%; }
.sc__card {
width: min(100%, 300px); background: $subject-blue; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3 $sp-4 $sp-2;
}
.sc__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sc__title { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: clamp(11px, 3.2vw, 13px); color: $stage-paper; }
.sc__ico { width: 16px; height: 16px; color: $stage-paper; }
.sc__reveal {
border: 0; cursor: pointer; background: $stage-paper; color: #6079b0;
border-radius: $r-pill; font-family: inherit; font-weight: 800;
font-size: clamp(10px, 2.8vw, 11px); padding: $sp-1 $sp-3; line-height: 1.3;
}
.sc__reveal:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.sc__zone { position: relative; margin-top: $sp-2; border-radius: $r-control; overflow: hidden; }
.sc__prize {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0;
aspect-ratio: 240 / 88; background: $subject-cream;
}
.sc__pct { margin: 0; font-weight: 800; font-size: clamp(30px, 9vw, 42px); line-height: 1; color: $subject-blue; }
.sc__pct i { font-style: normal; font-size: .5em; }
.sc__say { margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: rgba(23,20,26,.72); }
.sc__foil, .sc__demoFoil, .sc__remnant { position: absolute; inset: 0; }
.sc__foil { width: 100%; height: 100%; touch-action: none; }
.sc.is-open .sc__foil { visibility: hidden; }
.sc__demoFoil, .sc__remnant {
display: none; background: linear-gradient(100deg, #c9ccd6, #e8eaf0 45%, #b9bdc9);
}
.sc__demoFoil { align-items: center; justify-content: center; }
.sc__demoText { font-weight: 800; font-size: clamp(12px, 3.6vw, 15px); color: #4a4750; letter-spacing: .08em; }
.sc__remnant { left: 62%; transform-origin: top center; }
.sc__coin {
display: none; width: 30px; height: 30px; border-radius: 50%; top: calc(50% - 15px); left: 4%;
background: radial-gradient(circle at 34% 30%, #ebe6d6, #cab893 68%, #b59b69);
box-shadow: 0 4px 8px rgba(23,20,26,.35); border: 2px solid #ad9059;
}
.sc__live { margin: $sp-1 0 0; min-height: 1.2em; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: $stage-paper; }
// ── 격자 미리보기 루프: 동전이 왼쪽 62% 를 지나가며 wipe, 64% 에 남은 오른쪽 조각이
// 위로 말려 올라가고(rotateX) 76% 에 할인 확정 → 94% 복귀. zone 에 perspective.
.sc__zone { perspective: 300px; }
.sc.is-demo .sc__foil { visibility: hidden; }
.sc.is-demo .sc__demoFoil {
display: flex; clip-path: inset(0 0 0 0);
animation: scWipe 2s ease-out infinite;
}
@keyframes scWipe {
0%, 6% { clip-path: inset(0 0 0 0); }
63% { clip-path: inset(0 0 0 62%); }
63.5%, 100% { clip-path: inset(0 0 0 100%); }
}
.sc.is-demo .sc__remnant {
display: block;
animation: scCurl 2s ease-in-out infinite;
}
@keyframes scCurl {
0%, 63% { transform: rotateX(0deg); }
73%, 100% { transform: rotateX(-100deg); }
}
.sc.is-demo .sc__coin {
display: block; position: absolute;
animation: scCoin 2s ease-in-out infinite;
}
@keyframes scCoin {
0%, 6% { transform: translateX(0) rotate(0deg); }
63% { transform: translateX(var(--travel, 154px)) rotate(240deg); }
67% { transform: translateX(var(--travel, 154px)) rotate(240deg) scale(.6); }
71%, 100% { transform: translateX(calc(var(--travel, 154px) + 10px)) rotate(240deg) scale(0); }
}
.sc.is-demo .sc__prize { animation: scPop 2s ease-out infinite; }
@keyframes scPop {
0%, 74% { transform: scale(.9); }
79%, 93% { transform: scale(1); }
96%, 100% { transform: scale(.9); }
}
@media (max-width: 340px) {
.sc__card { padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__demoFoil,
.sc.is-demo .sc__remnant,
.sc.is-demo .sc__coin,
.sc.is-demo .sc__prize { animation: none !important; }
.sc.is-demo .sc__coin { display: none !important; }
// 움직임 없이 은박이 62% 까지 걷힌 정적 상태로 — 코인과 남은 조각은 감춘다
.sc.is-demo .sc__demoFoil { clip-path: inset(0 0 0 62%); }
.sc.is-demo .sc__remnant { display: none; }
}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);
}
.sc {
display: flex;
justify-content: center;
width: 100%;
}
.sc__card {
width: min(100%, 300px);
background: #2f6df6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px 8px;
}
.sc__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sc__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
color: #ffffff;
}
.sc__ico {
width: 16px;
height: 16px;
color: #ffffff;
}
.sc__reveal {
border: 0;
cursor: pointer;
background: #ffffff;
color: #6079b0;
border-radius: 999px;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 11px);
padding: 4px 12px;
line-height: 1.3;
}
.sc__reveal:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.sc__zone {
position: relative;
margin-top: 8px;
border-radius: 12px;
overflow: hidden;
}
.sc__prize {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
aspect-ratio: 240/88;
background: #fff7e6;
}
.sc__pct {
margin: 0;
font-weight: 800;
font-size: clamp(30px, 9vw, 42px);
line-height: 1;
color: #2f6df6;
}
.sc__pct i {
font-style: normal;
font-size: 0.5em;
}
.sc__say {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: rgba(23, 20, 26, 0.72);
}
.sc__foil, .sc__demoFoil, .sc__remnant {
position: absolute;
inset: 0;
}
.sc__foil {
width: 100%;
height: 100%;
touch-action: none;
}
.sc.is-open .sc__foil {
visibility: hidden;
}
.sc__demoFoil, .sc__remnant {
display: none;
background: linear-gradient(100deg, #c9ccd6, #e8eaf0 45%, #b9bdc9);
}
.sc__demoFoil {
align-items: center;
justify-content: center;
}
.sc__demoText {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 15px);
color: #4a4750;
letter-spacing: 0.08em;
}
.sc__remnant {
left: 62%;
transform-origin: top center;
}
.sc__coin {
display: none;
width: 30px;
height: 30px;
border-radius: 50%;
top: calc(50% - 15px);
left: 4%;
background: radial-gradient(circle at 34% 30%, #ebe6d6, #cab893 68%, #b59b69);
box-shadow: 0 4px 8px rgba(23, 20, 26, 0.35);
border: 2px solid #ad9059;
}
.sc__live {
margin: 4px 0 0;
min-height: 1.2em;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: #ffffff;
}
.sc__zone {
perspective: 300px;
}
.sc.is-demo .sc__foil {
visibility: hidden;
}
.sc.is-demo .sc__demoFoil {
display: flex;
clip-path: inset(0 0 0 0);
animation: scWipe 2s ease-out infinite;
}
@keyframes scWipe {
0%, 6% {
clip-path: inset(0 0 0 0);
}
63% {
clip-path: inset(0 0 0 62%);
}
63.5%, 100% {
clip-path: inset(0 0 0 100%);
}
}
.sc.is-demo .sc__remnant {
display: block;
animation: scCurl 2s ease-in-out infinite;
}
@keyframes scCurl {
0%, 63% {
transform: rotateX(0deg);
}
73%, 100% {
transform: rotateX(-100deg);
}
}
.sc.is-demo .sc__coin {
display: block;
position: absolute;
animation: scCoin 2s ease-in-out infinite;
}
@keyframes scCoin {
0%, 6% {
transform: translateX(0) rotate(0deg);
}
63% {
transform: translateX(var(--travel, 154px)) rotate(240deg);
}
67% {
transform: translateX(var(--travel, 154px)) rotate(240deg) scale(0.6);
}
71%, 100% {
transform: translateX(calc(var(--travel, 154px) + 10px)) rotate(240deg) scale(0);
}
}
.sc.is-demo .sc__prize {
animation: scPop 2s ease-out infinite;
}
@keyframes scPop {
0%, 74% {
transform: scale(0.9);
}
79%, 93% {
transform: scale(1);
}
96%, 100% {
transform: scale(0.9);
}
}
@media (max-width: 340px) {
.sc__card {
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__demoFoil,
.sc.is-demo .sc__remnant,
.sc.is-demo .sc__coin,
.sc.is-demo .sc__prize {
animation: none !important;
}
.sc.is-demo .sc__coin {
display: none !important;
}
.sc.is-demo .sc__demoFoil {
clip-path: inset(0 0 0 62%);
}
.sc.is-demo .sc__remnant {
display: none;
}
}var sc = document.getElementById('sc');
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 scratched = false;
// 은박 칠 — 작은 틈 균열 무늬까지
function paintFoil() {
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, foil.width, foil.height);
var grad = ctx.createLinearGradient(0, 0, foil.width, 0);
grad.addColorStop(0, '#c9ccd6');
grad.addColorStop(.5, '#e8eaf0');
grad.addColorStop(1, '#b9bdc9');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, foil.width, foil.height);
ctx.strokeStyle = 'rgba(23,20,26,.14)';
ctx.lineWidth = 1;
for (var i = 0; i < 26; i++) {
ctx.beginPath();
var x = Math.random() * foil.width, y = Math.random() * foil.height;
ctx.moveTo(x, y);
ctx.lineTo(x + (Math.random() * 24 - 12), y + (Math.random() * 10 - 5));
ctx.stroke();
}
}
paintFoil();
// 실제 긁기 — pointermove 로 destination-out 원을 계속 지운다
var drawing = false;
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 16, 0, Math.PI * 2);
ctx.fill();
}
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) };
}
zone.addEventListener('pointerdown', function (e) {
if (scratched) { return; }
sc.classList.remove('is-demo');
drawing = true;
var p = pos(e);
scratchAt(p.x, p.y);
if (foil.setPointerCapture) { foil.setPointerCapture(e.pointerId); }
});
zone.addEventListener('pointermove', function (e) {
if (!drawing || scratched) { return; }
var p = pos(e);
scratchAt(p.x, p.y);
if (clearedRatio() > .55) { finish(); }
});
window.addEventListener('pointerup', function () { drawing = false; });
// 얼마나 벗겨졌나 — 알파 샘플로 실측
function clearedRatio() {
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++; } }
return clear / total;
}
function finish() {
scratched = true;
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(0, 0, foil.width, foil.height);
sc.classList.add('is-open');
live.textContent = '割引率は30%でした';
}
revealBtn.addEventListener('click', function () { if (!scratched) { finish(); } });// ScratchCoupon — vanilla 판(run/283/demo/04-coupon-scratch-reveal/index.html) 의 React 포트.
// canvas 에 은박을 칠하고 포인터를 따라 destination-out 으로 지운다. 알파 샘플로 벗겨진 비율을
// 실측해 55% 를 넘으면 통째로 지우고 할인율을 알린다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ScratchCoupon.module.scss";
export type ScratchCouponProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const FOIL_WIDTH = 240;
const FOIL_HEIGHT = 88;
const BRUSH_RADIUS = 16;
const CLEAR_THRESHOLD = 0.55;
// 은박 칠 — 작은 틈 균열 무늬까지
function applyPaint(ctx: CanvasRenderingContext2D, width: number, height: number): void {
ctx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, width, height);
const grad = ctx.createLinearGradient(0, 0, width, 0);
grad.addColorStop(0, "#c9ccd6");
grad.addColorStop(0.5, "#e8eaf0");
grad.addColorStop(1, "#b9bdc9");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = "rgba(23,20,26,.14)";
ctx.lineWidth = 1;
for (let i = 0; i < 26; i++) {
ctx.beginPath();
const x = Math.random() * width;
const y = Math.random() * height;
ctx.moveTo(x, y);
ctx.lineTo(x + (Math.random() * 24 - 12), y + (Math.random() * 10 - 5));
ctx.stroke();
}
}
// 실제 긁기 — destination-out 원을 계속 지운다
function applyScratch(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(x, y, BRUSH_RADIUS, 0, Math.PI * 2);
ctx.fill();
}
// 얼마나 벗겨졌나 — 알파 샘플로 실측
function clearedRatio(ctx: CanvasRenderingContext2D, width: number, height: number): number {
const data = ctx.getImageData(0, 0, width, height).data;
let clear = 0;
const total = data.length / 4;
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 40) {
clear++;
}
}
return clear / total;
}
export default function ScratchCoupon({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: ScratchCouponProps) {
const foilRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<boolean>(false);
const [demo, setDemo] = useState(true);
const [scratched, setScratched] = useState(false);
const [live, setLive] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 은박은 마운트 때 한 번 칠한다 — 다시 칠하면 긁은 자리가 되살아난다
useEffect(() => {
const canvas = foilRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
applyPaint(ctx, FOIL_WIDTH, FOIL_HEIGHT);
}, []);
// 포인터를 떼면 어디서 떼든 긋기를 끝낸다
useEffect(() => {
function onUp(): void {
drawingRef.current = false;
}
window.addEventListener("pointerup", onUp);
return () => window.removeEventListener("pointerup", onUp);
}, []);
function getContext(): CanvasRenderingContext2D | null {
const canvas = foilRef.current;
if (!canvas) return null;
return canvas.getContext("2d");
}
function toCanvasPoint(event: ReactPointerEvent<HTMLDivElement>): { x: number; y: number } | null {
const canvas = foilRef.current;
if (!canvas) return null;
const r = canvas.getBoundingClientRect();
return {
x: (event.clientX - r.left) * (FOIL_WIDTH / r.width),
y: (event.clientY - r.top) * (FOIL_HEIGHT / r.height),
};
}
function applyFinish(ctx: CanvasRenderingContext2D): void {
setScratched(true);
ctx.globalCompositeOperation = "destination-out";
ctx.fillRect(0, 0, FOIL_WIDTH, FOIL_HEIGHT);
setLive("할인율은 30%였어요");
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
if (scratched) return;
setDemo(false);
drawingRef.current = true;
const ctx = getContext();
const p = toCanvasPoint(event);
if (!ctx || !p) return;
applyScratch(ctx, p.x, p.y);
const canvas = foilRef.current;
if (canvas && canvas.setPointerCapture) {
canvas.setPointerCapture(event.pointerId);
}
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (!drawingRef.current || scratched) return;
const ctx = getContext();
const p = toCanvasPoint(event);
if (!ctx || !p) return;
applyScratch(ctx, p.x, p.y);
if (clearedRatio(ctx, FOIL_WIDTH, FOIL_HEIGHT) > CLEAR_THRESHOLD) {
applyFinish(ctx);
}
}
function onReveal(): void {
if (scratched) return;
const ctx = getContext();
if (!ctx) return;
applyFinish(ctx);
}
const cls = [styles.root, demo ? styles.isDemo : "", scratched ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.head}>
<p className={styles.title}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M232,104c0,24-40,48-104,48S24,128,24,104,64,56,128,56,232,80,232,104Z" opacity="0.2"/><path d="M207.58,63.84C186.85,53.48,159.33,48,128,48S69.15,53.48,48.42,63.84,16,88.78,16,104v48c0,15.22,11.82,29.85,32.42,40.16S96.67,208,128,208s58.85-5.48,79.58-15.84S240,167.22,240,152V104C240,88.78,228.18,74.15,207.58,63.84ZM128,64c62.64,0,96,23.23,96,40s-33.36,40-96,40-96-23.23-96-40S65.36,64,128,64Zm-8,95.86v32c-19-.62-35-3.42-48-7.49V153.05A203.43,203.43,0,0,0,120,159.86Zm16,0a203.43,203.43,0,0,0,48-6.81v31.31c-13,4.07-29,6.87-48,7.49ZM32,152V133.53a82.88,82.88,0,0,0,16.42,10.63c2.43,1.21,5,2.35,7.58,3.43V178C40.17,170.16,32,160.29,32,152Zm168,26V147.59c2.61-1.08,5.15-2.22,7.58-3.43A82.88,82.88,0,0,0,224,133.53V152C224,160.29,215.83,170.16,200,178Z"/></svg>
<span>긁어 보세요</span>
</p>
<button type="button" className={styles.reveal} onClick={onReveal}>바로 보기</button>
</div>
<div className={styles.zone} onPointerDown={onPointerDown} onPointerMove={onPointerMove}>
<div className={styles.prize}>
<p className={styles.pct}>30<i>%</i></p>
<p className={styles.say}>숨겨진 할인</p>
</div>
<canvas className={styles.foil} ref={foilRef} width={FOIL_WIDTH} height={FOIL_HEIGHT} aria-hidden="true"></canvas>
<div className={styles.demoFoil} aria-hidden="true"><span className={styles.demoText}>은박을 긁어 주세요</span></div>
<div className={styles.remnant} aria-hidden="true"></div>
<div className={styles.coin} aria-hidden="true"></div>
</div>
<p className={styles.live} aria-live="polite">{live}</p>
</div>
</div>
</main>
);
}04 スクラッチクーポン — 計測 累積面積 22.853% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · クーポン デザイン 9選 — スタンプからルーレットまで
このデモが属する記事
クーポン デザイン 9選 — スタンプからルーレットまでクーポン デザインとは、割引を配るための見た目(チケット、スタンプカード、ルーレット、スクラッチ)をHTMLとCSSで組み立てる仕事です。9つとも480×300の舞台で実際に動かして測り、320px幅でもはみ出さないところまで直してあります この記事のデモをすべて見る


