Coupon Design: 9 CSS Coupons, Stamp to Spin
Coupon design is what a discount looks like on screen and how it behaves — notch, tear line, stamp slot, countdown.
01 coupon-ticket-notch
02 coupon-stamp-card
03 coupon-spin-wheel
04 coupon-scratch-reveal
05 coupon-giftcard-flip
06 coupon-stack-fan
07 coupon-barcode-scan
08 coupon-birthday-candle
09 coupon-expiry-fade
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Notched ticket coupon
- 02 Stamp collection card
- 03 Spin-to-win wheel
- 04 Scratch-off coupon
- 05 Flip gift card
- 06 Coupon stack fan
- 07 Barcode scan coupon
- 08 Birthday candle coupon
- 09 Expiring coupon
The order of these coupon design examples follows one shopper through a discount instead of running down a list of techniques. It opens with the two that simply hand a discount over: the ticket you save to a wallet (01) and the card that fills one slot per visit (02). Then come the four that turn getting one into a game — spin (03), scratch (04), flip (05), and the wallet fan where three offers argue for themselves (06). The last three are the moments around the counter: the barcode you hold up at the register (07), the birthday card that has to be opened before it counts (08), and the coupon whose clock is nearly out (09). Stage colors rotate through ink, yellow, orange, and paper — cream is a card color here, never a stage — so no two neighbors in the grid glare at each other. Once a discount is chosen, the line item lands in 9 Cart and Checkout UI Patterns; the mail that carries the code before any of this happens is in 9 Marketing Email Templates, and the New / Event / Shipping pills the fanned cards wear come from 9 Status Tag and Badge Styles. Lay the same paper box into an article and it becomes blockquote design; read back how many of these were redeemed and you are in chart interactions.
01Notched ticket coupon
What this discount coupon design shows
Half-circle notches bite into the card at the top and bottom of the 72% boundary, cut by two radial-gradient masks combined with mask-composite, and the tear line beside them is an ordinary 2px dashed border. Pressing Save dips the button, then the wallet count cuts from 0 coupons to 1 coupon — the calmest run of the nine at 5.972% cumulative change, though all 23 sampled frames move.

<main class="stage">
<div class="tk is-demo" id="tk">
<div class="tk__card" aria-label="First-purchase 30% ticket coupon">
<div class="tk__main">
<p class="tk__brand"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="tk__ico" aria-hidden="true"><path d="M200,128a32,32,0,0,0,32,32v32a8,8,0,0,1-8,8H96V56H224a8,8,0,0,1,8,8V96A32,32,0,0,0,200,128Z" opacity="0.2"/><path d="M232,104a8,8,0,0,0,8-8V64a16,16,0,0,0-16-16H32A16,16,0,0,0,16,64V96a8,8,0,0,0,8,8,24,24,0,0,1,0,48,8,8,0,0,0-8,8v32a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V160a8,8,0,0,0-8-8,24,24,0,0,1,0-48ZM32,167.2a40,40,0,0,0,0-78.4V64H88V192H32Zm192,0V192H104V64H224V88.8a40,40,0,0,0,0,78.4Z"/></svg><span>WELCOME</span></p>
<p class="tk__pct">30<i>%</i></p>
<p class="tk__label">First-purchase discount</p>
<p class="tk__meta">Until midnight today · code WELCOME30</p>
</div>
<div class="tk__stub">
<button type="button" class="tk__btn" id="saveBtn" aria-pressed="false" aria-label="Save coupon to wallet">
<span class="tk__btnLabel tk__btnLabel--save">Save</span>
<span class="tk__btnLabel tk__btnLabel--done" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="tk__tick" aria-hidden="true"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg>Saved</span>
</button>
</div>
</div>
<p class="tk__wallet" id="wallet" aria-live="polite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="tk__ico" aria-hidden="true"><path d="M224,80V192a8,8,0,0,1-8,8H56a16,16,0,0,1-16-16V56A16,16,0,0,0,56,72H216A8,8,0,0,1,224,80Z" opacity="0.2"/><path d="M216,64H56a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H56A24,24,0,0,0,32,56V184a24,24,0,0,0,24,24H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,128H56a8,8,0,0,1-8-8V78.63A23.84,23.84,0,0,0,56,80H216Zm-48-60a12,12,0,1,1,12,12A12,12,0,0,1,168,132Z"/></svg>
<span>Wallet</span>
<span class="tk__count"><b class="tk__num tk__num--zero" aria-hidden="true">0</b><b class="tk__num tk__num--one" aria-hidden="true">1</b><span class="tk__srNum" id="srNum">0 coupons</span></span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 노치 티켓 쿠폰 — 반원 노치는 mask + radial-gradient 로 진짜로 파고,
// 재단선은 점선 border. 격자 미리보기(.is-demo)는 2초 루프로 담기 동작을 보여준다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.tk { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.tk__card {
display: flex; width: min(100%, 380px); background: $subject-cream; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
// 반원 노치 두 개 — 오른쪽 경계 72% 지점 위·아래. mask-composite 로 두 구멍을 동시에.
-webkit-mask:
radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%),
radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
-webkit-mask-composite: destination-in;
mask:
radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%),
radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
mask-composite: intersect;
}
.tk.is-demo .tk__card { animation: tkFloat 2s ease-in-out infinite; }
.tk__main { flex: 1 1 72%; padding: $sp-3 $sp-4; min-width: 0; }
.tk__brand { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: clamp(9px, 2.6vw, 11px); letter-spacing: .14em; color: rgba(23,20,26,.72); }
.tk__ico { width: 14px; height: 14px; flex: 0 0 auto; color: $subject-blue; }
.tk__pct { margin: $sp-1 0 0; font-weight: 800; font-size: clamp(34px, 10vw, 46px); line-height: 1; color: $subject-blue; }
.tk__pct i { font-style: normal; font-size: .55em; }
.tk__label { margin: $sp-1 0 0; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); color: $subject-ink; }
.tk__meta { margin: $sp-1 0 0; font-weight: 500; font-size: clamp(9px, 2.6vw, 10px); color: rgba(23,20,26,.68); }
.tk__stub {
flex: 0 0 28%; border-left: 2px dashed rgba(23,20,26,.22);
display: flex; align-items: center; justify-content: center; padding: $sp-3 $sp-2;
}
.tk__btn {
position: relative; border: 0; cursor: pointer; background: $subject-blue; color: $stage-paper;
border-radius: $r-pill; font-weight: 800; font-size: clamp(11px, 3vw, 12px);
padding: $sp-2 $sp-4; font-family: inherit; line-height: 1.2;
transition: transform 140ms cubic-bezier(.2,.8,.2,1);
}
.tk__btn:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 2px; }
.tk__btnLabel { display: inline-flex; align-items: center; gap: 4px; }
.tk__btnLabel--done { position: absolute; inset: 0; justify-content: center; visibility: hidden; }
.tk.is-saved .tk__btnLabel--save { visibility: hidden; }
.tk.is-saved .tk__btnLabel--done { visibility: visible; }
.tk__tick { width: 12px; height: 12px; }
.tk__wallet {
display: inline-flex; align-items: center; gap: $sp-1; margin: 0;
background: rgba(255,247,230,.14); color: $subject-cream; border-radius: $r-pill;
padding: $sp-1 $sp-3; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px);
}
.tk__wallet .tk__ico { color: $subject-cream; }
.tk__count { position: relative; display: inline-block; min-width: 2.2em; text-align: right; font-weight: 800; }
.tk__num { position: absolute; right: 0; top: 50%; transform: translateY(-50%); }
.tk__num--one { visibility: hidden; }
.tk.is-saved .tk__num--zero { visibility: hidden; }
.tk.is-saved .tk__num--one { visibility: visible; }
.tk__srNum { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); margin: 0; }
// ── 격자 미리보기 루프: 카드 둥둥(연속) + 버튼 눌림(42%) + 보관함 숫자 컷(55%) → 90% 복귀
@keyframes tkFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
.tk.is-demo .tk__btn { animation: tkPress 2s $easing infinite; }
@keyframes tkPress {
0%, 40% { transform: scale(1); }
43% { transform: scale(.9); }
47%, 100% { transform: scale(1); }
}
.tk.is-demo .tk__btnLabel--save { animation: tkCutSave 2s steps(1, end) infinite; }
@keyframes tkCutSave { 0%, 45% { visibility: visible; } 45.5%, 89% { visibility: hidden; } 89.5%, 100% { visibility: visible; } }
.tk.is-demo .tk__btnLabel--done { animation: tkCutDone 2s steps(1, end) infinite; }
@keyframes tkCutDone { 0%, 45% { visibility: hidden; } 45.5%, 89% { visibility: visible; } 89.5%, 100% { visibility: hidden; } }
.tk.is-demo .tk__num--zero { animation: tkCutZero 2s steps(1, end) infinite; }
@keyframes tkCutZero { 0%, 54% { visibility: visible; } 54.5%, 89% { visibility: hidden; } 89.5%, 100% { visibility: visible; } }
.tk.is-demo .tk__num--one { animation: tkCutOne 2s steps(1, end) infinite; }
@keyframes tkCutOne { 0%, 54% { visibility: hidden; } 54.5%, 89% { visibility: visible; } 89.5%, 100% { visibility: hidden; } }
@media (max-width: 340px) {
.tk__main { padding: $sp-2 $sp-3; }
.tk__stub { padding: $sp-2 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.tk.is-demo .tk__card,
.tk.is-demo .tk__btn,
.tk.is-demo .tk__btnLabel--save,
.tk.is-demo .tk__btnLabel--done,
.tk.is-demo .tk__num--zero,
.tk.is-demo .tk__num--one { animation: none !important; }
.tk__btn { transition: none !important; }
}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);
}
.tk {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.tk__card {
display: flex;
width: min(100%, 380px);
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
-webkit-mask: radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%), radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
-webkit-mask-composite: destination-in;
mask: radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%), radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
mask-composite: intersect;
}
.tk.is-demo .tk__card {
animation: tkFloat 2s ease-in-out infinite;
}
.tk__main {
flex: 1 1 72%;
padding: 12px 16px;
min-width: 0;
}
.tk__brand {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(9px, 2.6vw, 11px);
letter-spacing: 0.14em;
color: rgba(23, 20, 26, 0.72);
}
.tk__ico {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: #2f6df6;
}
.tk__pct {
margin: 4px 0 0;
font-weight: 800;
font-size: clamp(34px, 10vw, 46px);
line-height: 1;
color: #2f6df6;
}
.tk__pct i {
font-style: normal;
font-size: 0.55em;
}
.tk__label {
margin: 4px 0 0;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
color: #17141a;
}
.tk__meta {
margin: 4px 0 0;
font-weight: 500;
font-size: clamp(9px, 2.6vw, 10px);
color: rgba(23, 20, 26, 0.68);
}
.tk__stub {
flex: 0 0 28%;
border-left: 2px dashed rgba(23, 20, 26, 0.22);
display: flex;
align-items: center;
justify-content: center;
padding: 12px 8px;
}
.tk__btn {
position: relative;
border: 0;
cursor: pointer;
background: #2f6df6;
color: #ffffff;
border-radius: 999px;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
padding: 8px 16px;
font-family: inherit;
line-height: 1.2;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tk__btn:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 2px;
}
.tk__btnLabel {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tk__btnLabel--done {
position: absolute;
inset: 0;
justify-content: center;
visibility: hidden;
}
.tk.is-saved .tk__btnLabel--save {
visibility: hidden;
}
.tk.is-saved .tk__btnLabel--done {
visibility: visible;
}
.tk__tick {
width: 12px;
height: 12px;
}
.tk__wallet {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
}
.tk__wallet .tk__ico {
color: #fff7e6;
}
.tk__count {
position: relative;
display: inline-block;
min-width: 2.2em;
text-align: right;
font-weight: 800;
}
.tk__num {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.tk__num--one {
visibility: hidden;
}
.tk.is-saved .tk__num--zero {
visibility: hidden;
}
.tk.is-saved .tk__num--one {
visibility: visible;
}
.tk__srNum {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
margin: 0;
}
@keyframes tkFloat {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.tk.is-demo .tk__btn {
animation: tkPress 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes tkPress {
0%, 40% {
transform: scale(1);
}
43% {
transform: scale(0.9);
}
47%, 100% {
transform: scale(1);
}
}
.tk.is-demo .tk__btnLabel--save {
animation: tkCutSave 2s steps(1, end) infinite;
}
@keyframes tkCutSave {
0%, 45% {
visibility: visible;
}
45.5%, 89% {
visibility: hidden;
}
89.5%, 100% {
visibility: visible;
}
}
.tk.is-demo .tk__btnLabel--done {
animation: tkCutDone 2s steps(1, end) infinite;
}
@keyframes tkCutDone {
0%, 45% {
visibility: hidden;
}
45.5%, 89% {
visibility: visible;
}
89.5%, 100% {
visibility: hidden;
}
}
.tk.is-demo .tk__num--zero {
animation: tkCutZero 2s steps(1, end) infinite;
}
@keyframes tkCutZero {
0%, 54% {
visibility: visible;
}
54.5%, 89% {
visibility: hidden;
}
89.5%, 100% {
visibility: visible;
}
}
.tk.is-demo .tk__num--one {
animation: tkCutOne 2s steps(1, end) infinite;
}
@keyframes tkCutOne {
0%, 54% {
visibility: hidden;
}
54.5%, 89% {
visibility: visible;
}
89.5%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.tk__main {
padding: 8px 12px;
}
.tk__stub {
padding: 8px 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.tk.is-demo .tk__card,
.tk.is-demo .tk__btn,
.tk.is-demo .tk__btnLabel--save,
.tk.is-demo .tk__btnLabel--done,
.tk.is-demo .tk__num--zero,
.tk.is-demo .tk__num--one {
animation: none !important;
}
.tk__btn {
transition: none !important;
}
}var tk = document.getElementById('tk');
var saveBtn = document.getElementById('saveBtn');
var srNum = document.getElementById('srNum');
saveBtn.addEventListener('click', function () {
tk.classList.remove('is-demo');
var saved = tk.classList.toggle('is-saved');
saveBtn.setAttribute('aria-pressed', saved ? 'true' : 'false');
srNum.textContent = saved ? '1 coupon' : '0 coupons';
});// TicketNotchCoupon — vanilla 판(run/283/demo/01-coupon-ticket-notch/index.html) 의 React 포트.
// 반원 노치는 mask + radial-gradient 로 진짜로 파고, 재단선은 점선 border.
// 담기를 누르면 미리보기 루프가 꺼지고 보관함 장수가 0↔1 로 바뀐다(vanilla 와 같은 동작).
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TicketNotchCoupon.module.scss";
export type TicketNotchCouponProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TicketNotchCoupon({
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: TicketNotchCouponProps) {
const [live, setLive] = useState(demo);
const [saved, setSaved] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// vanilla 의 click 핸들러와 같은 순서: 미리보기 루프를 떼고 보관 상태를 뒤집는다
function applySave() {
setLive(false);
setSaved((prev: boolean) => !prev);
}
const cls = [styles.root, live ? styles.isDemo : "", saved ? styles.isSaved : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<div className={styles.card} aria-label="첫 구매 30% 티켓 쿠폰">
<div className={styles.main}>
<p className={styles.brand}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true">
<path d="M200,128a32,32,0,0,0,32,32v32a8,8,0,0,1-8,8H96V56H224a8,8,0,0,1,8,8V96A32,32,0,0,0,200,128Z" opacity="0.2" />
<path d="M232,104a8,8,0,0,0,8-8V64a16,16,0,0,0-16-16H32A16,16,0,0,0,16,64V96a8,8,0,0,0,8,8,24,24,0,0,1,0,48,8,8,0,0,0-8,8v32a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V160a8,8,0,0,0-8-8,24,24,0,0,1,0-48ZM32,167.2a40,40,0,0,0,0-78.4V64H88V192H32Zm192,0V192H104V64H224V88.8a40,40,0,0,0,0,78.4Z" />
</svg>
<span>WELCOME</span>
</p>
<p className={styles.pct}>30<i>%</i></p>
<p className={styles.label}>첫 구매 할인</p>
<p className={styles.meta}>오늘 자정까지 · 코드 WELCOME30</p>
</div>
<div className={styles.stub}>
<button
type="button"
className={styles.btn}
aria-pressed={saved ? "true" : "false"}
aria-label="쿠폰 보관함에 담기"
onClick={applySave}
>
<span className={`${styles.btnLabel} ${styles.btnLabelSave}`}>담기</span>
<span className={`${styles.btnLabel} ${styles.btnLabelDone}`} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.tick} aria-hidden="true">
<path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2" />
<path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z" />
</svg>담김
</span>
</button>
</div>
</div>
<p className={styles.wallet} aria-live="polite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true">
<path d="M224,80V192a8,8,0,0,1-8,8H56a16,16,0,0,1-16-16V56A16,16,0,0,0,56,72H216A8,8,0,0,1,224,80Z" opacity="0.2" />
<path d="M216,64H56a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H56A24,24,0,0,0,32,56V184a24,24,0,0,0,24,24H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,128H56a8,8,0,0,1-8-8V78.63A23.84,23.84,0,0,0,56,80H216Zm-48-60a12,12,0,1,1,12,12A12,12,0,0,1,168,132Z" />
</svg>
<span>보관함</span>
<span className={styles.count}>
<b className={`${styles.num} ${styles.numZero}`} aria-hidden="true">0</b>
<b className={`${styles.num} ${styles.numOne}`} aria-hidden="true">1</b>
<span className={styles.srNum}>{saved ? "1장" : "0장"}</span>
</span>
</p>
</div>
</main>
);
}01 · Notched ticket coupon — Measured area 5.972% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
First-purchase download cards and promo landing pages.
.tk__card {
display: flex; width: min(100%, 380px); background: $subject-cream; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
// two half-circle notches at the 72% boundary — mask-composite punches both at once
-webkit-mask:
radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%),
radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
-webkit-mask-composite: destination-in;
mask:
radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%),
radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
mask-composite: intersect;
}
02Stamp collection card
What happens on screen
One order fills one slot: the stamp lands from scale(1.6) at -14deg, overshoots to .94 and settles at -8deg, and the same number is written into aria-valuenow so a screen reader hears the progress the eye sees. Ten slots fire 120ms apart, each off its own --k, and the run moves 19.372% of the stage across 19 of 23 frames before the Coupon issued chip pops.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="seal" viewBox="0 0 256 256" fill="currentColor"><path d="M232,128c0,12.51-17.82,21.95-22.68,33.69-4.68,11.32,1.42,30.65-7.78,39.85s-28.53,3.1-39.85,7.78C150,214.18,140.5,232,128,232s-22-17.82-33.69-22.68c-11.32-4.68-30.65,1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82,150,24,140.5,24,128s17.82-22,22.68-33.69C51.36,83,45.26,63.66,54.46,54.46S83,51.36,94.31,46.68C106.05,41.82,115.5,24,128,24S150,41.82,161.69,46.68c11.32,4.68,30.65-1.42,39.85,7.78s3.1,28.53,7.78,39.85C214.18,106.05,232,115.5,232,128Z" opacity="0.2"/><path d="M225.86,102.82c-3.77-3.94-7.67-8-9.14-11.57-1.36-3.27-1.44-8.69-1.52-13.94-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52-3.56-1.47-7.63-5.37-11.57-9.14C146.28,23.51,138.44,16,128,16s-18.27,7.51-25.18,14.14c-3.94,3.77-8,7.67-11.57,9.14C88,40.64,82.56,40.72,77.31,40.8c-9.76.15-20.82.31-28.51,8S41,67.55,40.8,77.31c-.08,5.25-.16,10.67-1.52,13.94-1.47,3.56-5.37,7.63-9.14,11.57C23.51,109.72,16,117.56,16,128s7.51,18.27,14.14,25.18c3.77,3.94,7.67,8,9.14,11.57,1.36,3.27,1.44,8.69,1.52,13.94.15,9.76.31,20.82,8,28.51s18.75,7.85,28.51,8c5.25.08,10.67.16,13.94,1.52,3.56,1.47,7.63,5.37,11.57,9.14C109.72,232.49,117.56,240,128,240s18.27-7.51,25.18-14.14c3.94-3.77,8-7.67,11.57-9.14,3.27-1.36,8.69-1.44,13.94-1.52,9.76-.15,20.82-.31,28.51-8s7.85-18.75,8-28.51c.08-5.25.16-10.67,1.52-13.94,1.47-3.56,5.37-7.63,9.14-11.57C232.49,146.28,240,138.44,240,128S232.49,109.73,225.86,102.82Zm-11.55,39.29c-4.79,5-9.75,10.17-12.38,16.52-2.52,6.1-2.63,13.07-2.73,19.82-.1,7-.21,14.33-3.32,17.43s-10.39,3.22-17.43,3.32c-6.75.1-13.72.21-19.82,2.73-6.35,2.63-11.52,7.59-16.52,12.38S132,224,128,224s-9.15-4.92-14.11-9.69-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82-2.63-6.35-7.59-11.52-12.38-16.52S32,132,32,128s4.92-9.15,9.69-14.11,9.75-10.17,12.38-16.52c2.52-6.1,2.63-13.07,2.73-19.82.1-7,.21-14.33,3.32-17.43S70.51,56.9,77.55,56.8c6.75-.1,13.72-.21,19.82-2.73,6.35-2.63,11.52-7.59,16.52-12.38S124,32,128,32s9.15,4.92,14.11,9.69,10.17,9.75,16.52,12.38c6.1,2.52,13.07,2.63,19.82,2.73,7,.1,14.33.21,17.43,3.32s3.22,10.39,3.32,17.43c.1,6.75.21,13.72,2.73,19.82,2.63,6.35,7.59,11.52,12.38,16.52S224,124,224,128,219.08,137.15,214.31,142.11ZM173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34Z"/></symbol>
<symbol id="coffee" viewBox="0 0 256 256" fill="currentColor"><path d="M208,88v48a88,88,0,0,1-51.3,80H83.3A88,88,0,0,1,32,136V88Z" opacity="0.2"/><path d="M80,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Zm40,8a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,120,64Zm32,0a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,152,64Zm96,56v8a40,40,0,0,1-37.51,39.91,96.59,96.59,0,0,1-27,40.09H208a8,8,0,0,1,0,16H32a8,8,0,0,1,0-16H56.54A96.3,96.3,0,0,1,24,136V88a8,8,0,0,1,8-8H208A40,40,0,0,1,248,120ZM200,96H40v40a80.27,80.27,0,0,0,45.12,72h69.76A80.27,80.27,0,0,0,200,136Zm32,24a24,24,0,0,0-16-22.62V136a95.78,95.78,0,0,1-1.2,15A24,24,0,0,0,232,128Z"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="sp is-demo" id="sp">
<div class="sp__card">
<div class="sp__head">
<p class="sp__title"><svg class="sp__ico" aria-hidden="true"><use href="#coffee"/></svg><span>Coffee stamp card</span></p>
<p class="sp__count"><span>Stamps</span> <b id="countNum">7/10</b></p>
</div>
<div class="sp__grid" role="progressbar" aria-label="Stamp progress" aria-valuemin="0" aria-valuemax="10" aria-valuenow="7" id="bar">
<span class="sp__slot"><span class="sp__stamp sp__stamp--in"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in sp__stamp--idle"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in sp__stamp--idle"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
<span class="sp__slot"><span class="sp__stamp sp__stamp--in sp__stamp--idle"><svg aria-hidden="true"><use href="#seal"/></svg></span></span>
</div>
<div class="sp__foot">
<button type="button" class="sp__btn" id="stampBtn">Stamp</button>
<p class="sp__issued" id="issued" aria-live="polite"><span class="sp__issuedChip">Coupon issued</span></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스탬프 적립 카드 — 도장은 scale 2→1 내리찍기 + 살짝 기울임. 진행률은 aria-valuenow 에 반영.
// 격자 미리보기(.is-demo)는 열 도장이 순서대로 찍히고 마지막에 쿠폰 발급 배지가 뜨는 2초 루프.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-yellow);
.sp { display: flex; justify-content: center; width: 100%; }
.sp__card {
width: min(100%, 330px); background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3 $sp-4; overflow: hidden;
}
.sp__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sp__title { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: clamp(11px, 3.2vw, 13px); color: $subject-ink; }
.sp__ico { width: 16px; height: 16px; color: $subject-blue; }
.sp__count { margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: rgba(23,20,26,.72); }
.sp__grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: $sp-2; margin-top: $sp-2; }
.sp__slot {
display: flex; align-items: center; justify-content: center; aspect-ratio: 1;
border: 2px dashed rgba(23,20,26,.18); border-radius: 50%; min-width: 0;
}
.sp__stamp { display: flex; width: 88%; height: 88%; color: $subject-blue; }
.sp__stamp svg { width: 100%; height: 100%; }
.sp__stamp--in { transform: rotate(-8deg); }
.sp__slot:nth-child(3n) .sp__stamp--in { transform: rotate(5deg); }
.sp__slot:nth-child(4n) .sp__stamp--in { transform: rotate(-3deg); }
.sp__stamp--new { animation: spStampIn 300ms $easing both; transform-origin: 50% 50%; }
@keyframes spStampIn {
0% { transform: scale(1.6) rotate(-14deg); }
70% { transform: scale(.94) rotate(-6deg); }
100% { transform: scale(1) rotate(-8deg); }
}
.sp__foot { position: relative; display: flex; justify-content: center; margin-top: $sp-3; min-height: 32px; }
.sp__btn {
border: 0; cursor: pointer; background: $subject-ink; color: $stage-yellow;
border-radius: $r-pill; font-weight: 800; font-size: clamp(11px, 3vw, 12px);
padding: $sp-2 $sp-5; font-family: inherit; line-height: 1.2;
transition: transform 140ms cubic-bezier(.2,.8,.2,1);
}
.sp__btn:active { transform: scale(.94); }
.sp__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.sp__btn:disabled { cursor: default; }
.sp__issued { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; margin: 0; visibility: hidden; }
.sp__issued.is-on { visibility: visible; }
.sp__issuedChip {
background: #a86253; color: $subject-cream; border-radius: $r-pill;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); padding: $sp-2 $sp-4;
animation: spBadgeIn 300ms $easing both;
}
@keyframes spBadgeIn { 0% { transform: scale(.4); } 70% { transform: scale(1.08); } 100% { transform: scale(1); } }
// ── 격자 미리보기 루프: 정적 도장 7개는 그대로 두고, 남은 3칸 + 리셋 컷으로 2초를 채운다.
// is-demo 동안은 정적 7개를 감추고 0→10 전체 시퀀스를 보여준다 (delay 는 낱개 속성 — 단축 리셋 함정).
.sp.is-demo .sp__stamp--in { visibility: hidden; }
.sp.is-demo .sp__slot:nth-child(n) .sp__stamp--in { animation: spLoop 2s ease-out infinite; animation-delay: calc(var(--k, 0) * 120ms); animation-fill-mode: backwards; }
.sp.is-demo .sp__slot:nth-child(1) .sp__stamp--in { --k: 0; }
.sp.is-demo .sp__slot:nth-child(2) .sp__stamp--in { --k: 1; }
.sp.is-demo .sp__slot:nth-child(3) .sp__stamp--in { --k: 2; }
.sp.is-demo .sp__slot:nth-child(4) .sp__stamp--in { --k: 3; }
.sp.is-demo .sp__slot:nth-child(5) .sp__stamp--in { --k: 4; }
.sp.is-demo .sp__slot:nth-child(6) .sp__stamp--in { --k: 5; }
.sp.is-demo .sp__slot:nth-child(7) .sp__stamp--in { --k: 6; }
.sp.is-demo .sp__slot:nth-child(8) .sp__stamp--in { --k: 7; }
.sp.is-demo .sp__slot:nth-child(9) .sp__stamp--in { --k: 8; }
.sp.is-demo .sp__slot:nth-child(10) .sp__stamp--in { --k: 9; }
// 슬롯 8~10 의 도장은 정적 상태에서는 비어 있는 칸으로 보이고 루프에서만 찍힌다
.sp:not(.is-demo) .sp__stamp--idle { visibility: hidden; }
@keyframes spLoop {
0% { visibility: hidden; transform: scale(1.6) rotate(-14deg); }
1.5% { visibility: visible; }
8% { transform: scale(.94) rotate(-6deg); }
12%, 96% { visibility: visible; transform: scale(1) rotate(-8deg); }
96.5%, 100% { visibility: hidden; transform: scale(1) rotate(-8deg); }
}
.sp.is-demo .sp__btn { animation: spBtnHide 2s steps(1, end) infinite; }
@keyframes spBtnHide { 0%, 72% { visibility: visible; } 72.5%, 96% { visibility: hidden; } 96.5%, 100% { visibility: visible; } }
.sp.is-demo .sp__issued { visibility: visible; }
.sp.is-demo .sp__issuedChip { animation: spBadgeLoop 2s ease-out infinite; }
@keyframes spBadgeLoop {
0%, 71% { transform: scale(0); }
74% { transform: scale(1.1); }
77%, 95% { transform: scale(1); }
97%, 100% { transform: scale(0); }
}
@media (max-width: 340px) {
.sp__card { padding: $sp-2 $sp-3; }
.sp__grid { gap: $sp-1; margin-top: $sp-1; }
.sp__foot { margin-top: $sp-1; min-height: 24px; }
.sp__slot { border-width: 1px; }
.sp__btn { padding: $sp-1 $sp-4; }
.sp__issuedChip { padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__slot .sp__stamp--in,
.sp.is-demo .sp__btn,
.sp.is-demo .sp__issuedChip,
.sp__stamp--new,
.sp__issuedChip { animation: none !important; }
.sp.is-demo .sp__stamp--in { visibility: visible !important; }
.sp__btn { transition: none !important; }
}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);
}
.sp {
display: flex;
justify-content: center;
width: 100%;
}
.sp__card {
width: min(100%, 330px);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px;
overflow: hidden;
}
.sp__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sp__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
color: #17141a;
}
.sp__ico {
width: 16px;
height: 16px;
color: #2f6df6;
}
.sp__count {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(23, 20, 26, 0.72);
}
.sp__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-top: 8px;
}
.sp__slot {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: 2px dashed rgba(23, 20, 26, 0.18);
border-radius: 50%;
min-width: 0;
}
.sp__stamp {
display: flex;
width: 88%;
height: 88%;
color: #2f6df6;
}
.sp__stamp svg {
width: 100%;
height: 100%;
}
.sp__stamp--in {
transform: rotate(-8deg);
}
.sp__slot:nth-child(3n) .sp__stamp--in {
transform: rotate(5deg);
}
.sp__slot:nth-child(4n) .sp__stamp--in {
transform: rotate(-3deg);
}
.sp__stamp--new {
animation: spStampIn 300ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
transform-origin: 50% 50%;
}
@keyframes spStampIn {
0% {
transform: scale(1.6) rotate(-14deg);
}
70% {
transform: scale(0.94) rotate(-6deg);
}
100% {
transform: scale(1) rotate(-8deg);
}
}
.sp__foot {
position: relative;
display: flex;
justify-content: center;
margin-top: 12px;
min-height: 32px;
}
.sp__btn {
border: 0;
cursor: pointer;
background: #17141a;
color: #ffd23f;
border-radius: 999px;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
padding: 8px 20px;
font-family: inherit;
line-height: 1.2;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__btn:active {
transform: scale(0.94);
}
.sp__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.sp__btn:disabled {
cursor: default;
}
.sp__issued {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
visibility: hidden;
}
.sp__issued.is-on {
visibility: visible;
}
.sp__issuedChip {
background: #a86253;
color: #fff7e6;
border-radius: 999px;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
padding: 8px 16px;
animation: spBadgeIn 300ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes spBadgeIn {
0% {
transform: scale(0.4);
}
70% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
.sp.is-demo .sp__stamp--in {
visibility: hidden;
}
.sp.is-demo .sp__slot:nth-child(n) .sp__stamp--in {
animation: spLoop 2s ease-out infinite;
animation-delay: calc(var(--k, 0) * 120ms);
animation-fill-mode: backwards;
}
.sp.is-demo .sp__slot:nth-child(1) .sp__stamp--in {
--k: 0;
}
.sp.is-demo .sp__slot:nth-child(2) .sp__stamp--in {
--k: 1;
}
.sp.is-demo .sp__slot:nth-child(3) .sp__stamp--in {
--k: 2;
}
.sp.is-demo .sp__slot:nth-child(4) .sp__stamp--in {
--k: 3;
}
.sp.is-demo .sp__slot:nth-child(5) .sp__stamp--in {
--k: 4;
}
.sp.is-demo .sp__slot:nth-child(6) .sp__stamp--in {
--k: 5;
}
.sp.is-demo .sp__slot:nth-child(7) .sp__stamp--in {
--k: 6;
}
.sp.is-demo .sp__slot:nth-child(8) .sp__stamp--in {
--k: 7;
}
.sp.is-demo .sp__slot:nth-child(9) .sp__stamp--in {
--k: 8;
}
.sp.is-demo .sp__slot:nth-child(10) .sp__stamp--in {
--k: 9;
}
.sp:not(.is-demo) .sp__stamp--idle {
visibility: hidden;
}
@keyframes spLoop {
0% {
visibility: hidden;
transform: scale(1.6) rotate(-14deg);
}
1.5% {
visibility: visible;
}
8% {
transform: scale(0.94) rotate(-6deg);
}
12%, 96% {
visibility: visible;
transform: scale(1) rotate(-8deg);
}
96.5%, 100% {
visibility: hidden;
transform: scale(1) rotate(-8deg);
}
}
.sp.is-demo .sp__btn {
animation: spBtnHide 2s steps(1, end) infinite;
}
@keyframes spBtnHide {
0%, 72% {
visibility: visible;
}
72.5%, 96% {
visibility: hidden;
}
96.5%, 100% {
visibility: visible;
}
}
.sp.is-demo .sp__issued {
visibility: visible;
}
.sp.is-demo .sp__issuedChip {
animation: spBadgeLoop 2s ease-out infinite;
}
@keyframes spBadgeLoop {
0%, 71% {
transform: scale(0);
}
74% {
transform: scale(1.1);
}
77%, 95% {
transform: scale(1);
}
97%, 100% {
transform: scale(0);
}
}
@media (max-width: 340px) {
.sp__card {
padding: 8px 12px;
}
.sp__grid {
gap: 4px;
margin-top: 4px;
}
.sp__foot {
margin-top: 4px;
min-height: 24px;
}
.sp__slot {
border-width: 1px;
}
.sp__btn {
padding: 4px 16px;
}
.sp__issuedChip {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__slot .sp__stamp--in,
.sp.is-demo .sp__btn,
.sp.is-demo .sp__issuedChip,
.sp__stamp--new,
.sp__issuedChip {
animation: none !important;
}
.sp.is-demo .sp__stamp--in {
visibility: visible !important;
}
.sp__btn {
transition: none !important;
}
}var sp = document.getElementById('sp');
var slots = Array.prototype.slice.call(document.querySelectorAll('.sp__slot'));
var stampBtn = document.getElementById('stampBtn');
var bar = document.getElementById('bar');
var issued = document.getElementById('issued');
var countNum = document.getElementById('countNum');
var count = 7;
function render() {
bar.setAttribute('aria-valuenow', String(count));
countNum.textContent = count + '/10';
var full = count >= 10;
issued.classList.toggle('is-on', full);
stampBtn.disabled = full;
}
stampBtn.addEventListener('click', function () {
sp.classList.remove('is-demo');
if (count >= 10) { return; }
var target = slots[count].querySelector('.sp__stamp');
target.classList.remove('sp__stamp--idle');
target.classList.add('sp__stamp--new');
count = count + 1;
render();
});
render();// StampCard — vanilla 판(run/283/demo/02-coupon-stamp-card/index.html) 의 React 포트.
// 도장은 scale 1.6→1 내리찍기 + 살짝 기울임. 진행률은 role="progressbar" 의 aria-valuenow 에 그대로 실린다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StampCard.module.scss";
export type StampCardProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 칸 열 개 — vanilla 의 열 개 .sp__slot 과 같은 수 */
const SLOTS: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
/** 처음 찍혀 있는 도장 수. 이보다 뒤 칸은 이번에 찍은 것이라 내리찍기 애니메이션이 붙는다 */
const START = 7;
const GOAL = 10;
export default function StampCard({
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: StampCardProps) {
const [live, setLive] = useState(demo);
const [count, setCount] = useState(START);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const full = count >= GOAL;
// vanilla 의 click 핸들러와 같은 순서: 미리보기 루프를 떼고 다음 칸에 도장을 찍는다
function applyStamp() {
setLive(false);
if (count >= GOAL) {
return;
}
setCount(count + 1);
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="seal" viewBox="0 0 256 256" fill="currentColor">
<path d="M232,128c0,12.51-17.82,21.95-22.68,33.69-4.68,11.32,1.42,30.65-7.78,39.85s-28.53,3.1-39.85,7.78C150,214.18,140.5,232,128,232s-22-17.82-33.69-22.68c-11.32-4.68-30.65,1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82,150,24,140.5,24,128s17.82-22,22.68-33.69C51.36,83,45.26,63.66,54.46,54.46S83,51.36,94.31,46.68C106.05,41.82,115.5,24,128,24S150,41.82,161.69,46.68c11.32,4.68,30.65-1.42,39.85,7.78s3.1,28.53,7.78,39.85C214.18,106.05,232,115.5,232,128Z" opacity="0.2" />
<path d="M225.86,102.82c-3.77-3.94-7.67-8-9.14-11.57-1.36-3.27-1.44-8.69-1.52-13.94-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52-3.56-1.47-7.63-5.37-11.57-9.14C146.28,23.51,138.44,16,128,16s-18.27,7.51-25.18,14.14c-3.94,3.77-8,7.67-11.57,9.14C88,40.64,82.56,40.72,77.31,40.8c-9.76.15-20.82.31-28.51,8S41,67.55,40.8,77.31c-.08,5.25-.16,10.67-1.52,13.94-1.47,3.56-5.37,7.63-9.14,11.57C23.51,109.72,16,117.56,16,128s7.51,18.27,14.14,25.18c3.77,3.94,7.67,8,9.14,11.57,1.36,3.27,1.44,8.69,1.52,13.94.15,9.76.31,20.82,8,28.51s18.75,7.85,28.51,8c5.25.08,10.67.16,13.94,1.52,3.56,1.47,7.63,5.37,11.57,9.14C109.72,232.49,117.56,240,128,240s18.27-7.51,25.18-14.14c3.94-3.77,8-7.67,11.57-9.14,3.27-1.36,8.69-1.44,13.94-1.52,9.76-.15,20.82-.31,28.51-8s7.85-18.75,8-28.51c.08-5.25.16-10.67,1.52-13.94,1.47-3.56,5.37-7.63,9.14-11.57C232.49,146.28,240,138.44,240,128S232.49,109.73,225.86,102.82Zm-11.55,39.29c-4.79,5-9.75,10.17-12.38,16.52-2.52,6.1-2.63,13.07-2.73,19.82-.1,7-.21,14.33-3.32,17.43s-10.39,3.22-17.43,3.32c-6.75.1-13.72.21-19.82,2.73-6.35,2.63-11.52,7.59-16.52,12.38S132,224,128,224s-9.15-4.92-14.11-9.69-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82-2.63-6.35-7.59-11.52-12.38-16.52S32,132,32,128s4.92-9.15,9.69-14.11,9.75-10.17,12.38-16.52c2.52-6.1,2.63-13.07,2.73-19.82.1-7,.21-14.33,3.32-17.43S70.51,56.9,77.55,56.8c6.75-.1,13.72-.21,19.82-2.73,6.35-2.63,11.52-7.59,16.52-12.38S124,32,128,32s9.15,4.92,14.11,9.69,10.17,9.75,16.52,12.38c6.1,2.52,13.07,2.63,19.82,2.73,7,.1,14.33.21,17.43,3.32s3.22,10.39,3.32,17.43c.1,6.75.21,13.72,2.73,19.82,2.63,6.35,7.59,11.52,12.38,16.52S224,124,224,128,219.08,137.15,214.31,142.11ZM173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34Z" />
</symbol>
<symbol id="coffee" viewBox="0 0 256 256" fill="currentColor">
<path d="M208,88v48a88,88,0,0,1-51.3,80H83.3A88,88,0,0,1,32,136V88Z" opacity="0.2" />
<path d="M80,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Zm40,8a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,120,64Zm32,0a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,152,64Zm96,56v8a40,40,0,0,1-37.51,39.91,96.59,96.59,0,0,1-27,40.09H208a8,8,0,0,1,0,16H32a8,8,0,0,1,0-16H56.54A96.3,96.3,0,0,1,24,136V88a8,8,0,0,1,8-8H208A40,40,0,0,1,248,120ZM200,96H40v40a80.27,80.27,0,0,0,45.12,72h69.76A80.27,80.27,0,0,0,200,136Zm32,24a24,24,0,0,0-16-22.62V136a95.78,95.78,0,0,1-1.2,15A24,24,0,0,0,232,128Z" />
</symbol>
</defs>
</svg>
<div className={cls}>
<div className={styles.card}>
<div className={styles.head}>
<p className={styles.title}>
<svg className={styles.ico} aria-hidden="true"><use href="#coffee" /></svg>
<span>커피 적립 카드</span>
</p>
<p className={styles.count}><span>적립</span> <b>{count}/{GOAL}</b></p>
</div>
<div
className={styles.grid}
role="progressbar"
aria-label="적립 도장 진행"
aria-valuemin={0}
aria-valuemax={GOAL}
aria-valuenow={count}
>
{SLOTS.map(function (i: number) {
const idle = i >= count;
const fresh = i >= START && i < count;
const stampCls = [
styles.stamp,
styles.stampIn,
idle ? styles.stampIdle : "",
fresh ? styles.stampNew : "",
].filter(Boolean).join(" ");
return (
<span className={styles.slot} key={i}>
<span className={stampCls}><svg aria-hidden="true"><use href="#seal" /></svg></span>
</span>
);
})}
</div>
<div className={styles.foot}>
<button type="button" className={styles.btn} onClick={applyStamp} disabled={full}>도장 찍기</button>
<p className={`${styles.issued} ${full ? styles.issuedOn : ""}`} aria-live="polite">
<span className={styles.issuedChip}>쿠폰 발급 완료</span>
</p>
</div>
</div>
</div>
</main>
);
}02 · Stamp collection card — Measured area 19.372% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Cafe and membership loyalty cards, or the stamp board on an order-done screen.
.sp__stamp--new { animation: spStampIn 300ms $easing both; transform-origin: 50% 50%; }
@keyframes spStampIn {
0% { transform: scale(1.6) rotate(-14deg); }
70% { transform: scale(.94) rotate(-6deg); }
100% { transform: scale(1) rotate(-8deg); }
}
// each slot carries its own --k, so the ten stamps land 120ms apart
.sp.is-demo .sp__slot:nth-child(n) .sp__stamp--in { animation: spLoop 2s ease-out infinite; animation-delay: calc(var(--k, 0) * 120ms); animation-fill-mode: backwards; }
@keyframes spLoop {
0% { visibility: hidden; transform: scale(1.6) rotate(-14deg); }
1.5% { visibility: visible; }
8% { transform: scale(.94) rotate(-6deg); }
12%, 96% { visibility: visible; transform: scale(1) rotate(-8deg); }
96.5%, 100% { visibility: hidden; transform: scale(1) rotate(-8deg); }
}
03Spin-to-win wheel
What moves
Eight prize slices come out of a single conic-gradient() with no SVG and no image file behind them. The preview turns exactly 1800deg — five full revolutions — so it ends on the angle it began at and never teleports; it is the second-busiest item here, 32.835% of the stage changing in total, with one frame alone at 25.129%.

<main class="stage">
<div class="wh is-demo" id="wh">
<div class="wh__rig">
<div class="wh__pointer" aria-hidden="true"></div>
<div class="wh__disc" id="disc">
<div class="wh__ring" aria-hidden="true"></div>
<span class="wh__lbl" style="--k: 0"><b>10%</b></span>
<span class="wh__lbl wh__lbl--b" style="--k: 1"><b>Miss</b></span>
<span class="wh__lbl" style="--k: 2"><b>20%</b></span>
<span class="wh__lbl wh__lbl--b" style="--k: 3"><b>Miss</b></span>
<span class="wh__lbl" style="--k: 4"><b>30%</b></span>
<span class="wh__lbl wh__lbl--b" style="--k: 5"><b>Miss</b></span>
<span class="wh__lbl" style="--k: 6"><b>10%</b></span>
<span class="wh__lbl wh__lbl--b" style="--k: 7"><b>Miss</b></span>
</div>
<button type="button" class="wh__hub" id="hub">Draw</button>
</div>
<p class="wh__result" aria-live="polite"><span class="wh__msg wh__msg--idle">Spin the wheel to win a discount</span><span class="wh__msg wh__msg--spin" aria-hidden="true">Spinning</span><span class="wh__msg wh__msg--win" aria-hidden="true">You won 30% off!</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 돌림판 쿠폰 룰렛 — 여덟 칸은 conic-gradient 한 줄로 칠한다.
// 미리보기 루프는 1800deg(정확히 5바퀴)를 돌아 처음과 같은 각으로 돌아온다 — 텔레포트 없음.
// 실제 클릭은 transition 감속 회전(랜덤 당첨칸), 격자 루프와 별개.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.wh { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.wh__rig { position: relative; width: clamp(168px, 48vw, 204px); aspect-ratio: 1; }
.wh__pointer {
position: absolute; top: -6px; left: 50%; transform: translateX(-50%);
width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent;
border-top: 16px solid $stage-paper; z-index: 2; filter: drop-shadow(0 2px 2px rgba(23,20,26,.3));
}
.wh__disc {
position: relative; width: 100%; height: 100%; border-radius: 50%;
background:
conic-gradient(
$subject-cream 0deg 45deg,
#5066a2 45deg 90deg,
$subject-cream 90deg 135deg,
#5066a2 135deg 180deg,
$subject-cream 180deg 225deg,
#5066a2 225deg 270deg,
$subject-cream 270deg 315deg,
#5066a2 315deg 360deg
);
box-shadow: $shadow-raised;
transition: transform 1.8s cubic-bezier(.12, .8, .08, 1);
}
// 링은 disc 안쪽에 — 바깥(-6px)에 두면 회전한 경계 상자가 무대를 넘어 BD9 가 깨진다
.wh__ring { position: absolute; inset: 0; border-radius: 50%; border: 4px solid rgba(255,247,230,.85); }
// 라벨은 칸마다 작은 조각으로 — inset:0 짜리 판을 통째로 돌리면 회전한 경계 상자가
// 원판 지름의 √2 배(224 → 310px)로 부풀어 무대를 넘는다(BD9 실측).
.wh__lbl {
position: absolute; left: calc(50% - 22px); top: calc(50% - 10px);
width: 44px; height: 20px; pointer-events: none;
// 칸 하나가 45deg 이므로 라벨은 칸 **중앙**인 k*45+22.5deg 에 놓는다
transform: rotate(calc(var(--k) * 45deg + 22.5deg)) translateY(var(--label-r, -84px));
}
.wh__lbl b {
display: block; text-align: center; line-height: 20px;
font-weight: 800; font-size: clamp(10px, 3vw, 13px); color: $subject-ink;
transform: rotate(calc(var(--k) * -45deg - 22.5deg));
}
.wh__lbl--b b { color: $subject-cream; }
// 허브는 판(.wh__disc) 밖 — 판 안에 두면 회전과 함께 「뽑기」 글자까지 거꾸로 돈다(클릭 실측)
.wh__hub {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 34%; aspect-ratio: 1; border: 0; cursor: pointer; border-radius: 50%;
background: $stage-paper; color: $subject-ink; font-family: inherit;
font-weight: 800; font-size: clamp(12px, 3.6vw, 15px); line-height: 1;
box-shadow: $shadow-press; z-index: 1;
transition: transform 140ms cubic-bezier(.2,.8,.2,1);
}
.wh__hub:active { transform: translate(-50%, -50%) scale(.92); }
.wh__hub:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 3px; }
// 주황 무대 위 흰 글자는 3.32:1 로 AA 미달(실측) — 문구는 잉크 알약 위에 올린다
.wh__result { position: relative; margin: 0; min-height: 1.9em; width: min(100%, 300px); text-align: center; }
.wh__msg {
display: inline-block; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px);
color: $subject-cream; background: $subject-ink; border-radius: $r-pill; padding: 0 $sp-3; line-height: 1.7;
}
.wh__msg--spin, .wh__msg--win { position: absolute; left: 50%; top: 0; transform: translateX(-50%); visibility: hidden; }
.wh__msg--win { color: $stage-yellow; }
.wh.is-spinning .wh__msg--idle { visibility: hidden; }
.wh.is-spinning .wh__msg--spin { visibility: visible; }
.wh.is-won .wh__msg--idle { visibility: hidden; }
.wh.is-won .wh__msg--win { visibility: visible; }
// ── 격자 미리보기 루프: 5바퀴 감속 회전 → 82% 에 결과 컷 → 95% 복귀(회전각은 정확히 0으로)
.wh.is-demo .wh__disc { animation: whSpin 2s cubic-bezier(.12, .8, .08, 1) infinite; transition: none; }
@keyframes whSpin {
0% { transform: rotate(0deg); }
82%, 100% { transform: rotate(1800deg); }
}
.wh.is-demo .wh__msg--idle { animation: whCutIdle 2s steps(1, end) infinite; }
@keyframes whCutIdle { 0%, 8% { visibility: visible; } 8.5%, 94% { visibility: hidden; } 94.5%, 100% { visibility: visible; } }
.wh.is-demo .wh__msg--spin { animation: whCutSpin 2s steps(1, end) infinite; }
@keyframes whCutSpin { 0%, 8% { visibility: hidden; } 8.5%, 81% { visibility: visible; } 81.5%, 100% { visibility: hidden; } }
.wh.is-demo .wh__msg--win { animation: whCutWin 2s steps(1, end) infinite; }
@keyframes whCutWin { 0%, 81% { visibility: hidden; } 81.5%, 94% { visibility: visible; } 94.5%, 100% { visibility: hidden; } }
@media (max-width: 340px) {
// 320px 무대의 세로 여유는 174px 뿐 — 판 지름을 먼저 줄인다
.wh__rig { width: 132px; }
.wh { gap: $sp-1; --label-r: -48px; }
.wh__msg { font-size: 10px; padding: 0 $sp-2; }
.wh__lbl b { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wh.is-demo .wh__disc,
.wh.is-demo .wh__msg--idle,
.wh.is-demo .wh__msg--spin,
.wh.is-demo .wh__msg--win { animation: none !important; }
.wh__disc, .wh__hub { transition: none !important; }
// 회전 없이 결과만 즉시 보여준다
.wh.is-demo .wh__msg--idle { visibility: hidden; }
.wh.is-demo .wh__msg--win { visibility: visible; }
}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);
}
.wh {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.wh__rig {
position: relative;
width: clamp(168px, 48vw, 204px);
aspect-ratio: 1;
}
.wh__pointer {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 16px solid #ffffff;
z-index: 2;
filter: drop-shadow(0 2px 2px rgba(23, 20, 26, 0.3));
}
.wh__disc {
position: relative;
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(#fff7e6 0deg 45deg, #5066a2 45deg 90deg, #fff7e6 90deg 135deg, #5066a2 135deg 180deg, #fff7e6 180deg 225deg, #5066a2 225deg 270deg, #fff7e6 270deg 315deg, #5066a2 315deg 360deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 1.8s cubic-bezier(0.12, 0.8, 0.08, 1);
}
.wh__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 4px solid rgba(255, 247, 230, 0.85);
}
.wh__lbl {
position: absolute;
left: calc(50% - 22px);
top: calc(50% - 10px);
width: 44px;
height: 20px;
pointer-events: none;
transform: rotate(calc(var(--k) * 45deg + 22.5deg)) translateY(var(--label-r, -84px));
}
.wh__lbl b {
display: block;
text-align: center;
line-height: 20px;
font-weight: 800;
font-size: clamp(10px, 3vw, 13px);
color: #17141a;
transform: rotate(calc(var(--k) * -45deg - 22.5deg));
}
.wh__lbl--b b {
color: #fff7e6;
}
.wh__hub {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 34%;
aspect-ratio: 1;
border: 0;
cursor: pointer;
border-radius: 50%;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-weight: 800;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
z-index: 1;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wh__hub:active {
transform: translate(-50%, -50%) scale(0.92);
}
.wh__hub:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 3px;
}
.wh__result {
position: relative;
margin: 0;
min-height: 1.9em;
width: min(100%, 300px);
text-align: center;
}
.wh__msg {
display: inline-block;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
color: #fff7e6;
background: #17141a;
border-radius: 999px;
padding: 0 12px;
line-height: 1.7;
}
.wh__msg--spin, .wh__msg--win {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
visibility: hidden;
}
.wh__msg--win {
color: #ffd23f;
}
.wh.is-spinning .wh__msg--idle {
visibility: hidden;
}
.wh.is-spinning .wh__msg--spin {
visibility: visible;
}
.wh.is-won .wh__msg--idle {
visibility: hidden;
}
.wh.is-won .wh__msg--win {
visibility: visible;
}
.wh.is-demo .wh__disc {
animation: whSpin 2s cubic-bezier(0.12, 0.8, 0.08, 1) infinite;
transition: none;
}
@keyframes whSpin {
0% {
transform: rotate(0deg);
}
82%, 100% {
transform: rotate(1800deg);
}
}
.wh.is-demo .wh__msg--idle {
animation: whCutIdle 2s steps(1, end) infinite;
}
@keyframes whCutIdle {
0%, 8% {
visibility: visible;
}
8.5%, 94% {
visibility: hidden;
}
94.5%, 100% {
visibility: visible;
}
}
.wh.is-demo .wh__msg--spin {
animation: whCutSpin 2s steps(1, end) infinite;
}
@keyframes whCutSpin {
0%, 8% {
visibility: hidden;
}
8.5%, 81% {
visibility: visible;
}
81.5%, 100% {
visibility: hidden;
}
}
.wh.is-demo .wh__msg--win {
animation: whCutWin 2s steps(1, end) infinite;
}
@keyframes whCutWin {
0%, 81% {
visibility: hidden;
}
81.5%, 94% {
visibility: visible;
}
94.5%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.wh__rig {
width: 132px;
}
.wh {
gap: 4px;
--label-r: -48px;
}
.wh__msg {
font-size: 10px;
padding: 0 8px;
}
.wh__lbl b {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wh.is-demo .wh__disc,
.wh.is-demo .wh__msg--idle,
.wh.is-demo .wh__msg--spin,
.wh.is-demo .wh__msg--win {
animation: none !important;
}
.wh__disc, .wh__hub {
transition: none !important;
}
.wh.is-demo .wh__msg--idle {
visibility: hidden;
}
.wh.is-demo .wh__msg--win {
visibility: visible;
}
}var wh = document.getElementById('wh');
var disc = document.getElementById('disc');
var hub = document.getElementById('hub');
var spinning = false;
hub.addEventListener('click', function () {
if (spinning) { return; }
wh.classList.remove('is-demo');
spinning = true;
wh.classList.add('is-spinning');
var picks = [0, 2, 4, 6];
var idx = picks[Math.floor(Math.random() * picks.length)];
var perSlice = 45;
var target = 1800 + (360 - (idx * perSlice + perSlice / 2));
disc.style.transform = 'rotate(' + target + 'deg)';
window.setTimeout(function () {
wh.classList.remove('is-spinning');
wh.classList.add('is-won');
spinning = false;
}, 1900);
});// SpinWheelCoupon — vanilla 판(run/283/demo/03-coupon-spin-wheel/index.html) 의 React 포트.
// 여덟 칸은 conic-gradient 한 줄, 라벨은 칸 중앙(k*45+22.5deg)에 놓고 글자만 역회전시킨다.
// 실제 클릭은 transition 감속 회전(랜덤 당첨칸), 격자 미리보기 루프와 별개.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpinWheelCoupon.module.scss";
export type SpinWheelCouponProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SliceVars = CSSProperties & { "--k"?: number };
type Slice = { k: number; text: string };
/** 여덟 칸 — 짝수 칸이 당첨(크림 바탕), 홀수 칸이 꽝(파란 바탕) */
const SLICES: Slice[] = [
{ k: 0, text: "10%" },
{ k: 1, text: "꽝" },
{ k: 2, text: "20%" },
{ k: 3, text: "꽝" },
{ k: 4, text: "30%" },
{ k: 5, text: "꽝" },
{ k: 6, text: "10%" },
{ k: 7, text: "꽝" },
];
/** 멈출 수 있는 칸(당첨칸만) — vanilla 의 picks 와 같다 */
const PICKS: number[] = [0, 2, 4, 6];
const PER_SLICE = 45;
/** 감속 회전이 끝나는 시각(ms) — vanilla 의 setTimeout 1900 과 같다 */
const SETTLE_MS = 1900;
export default function SpinWheelCoupon({
demo = true,
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: SpinWheelCouponProps) {
const [live, setLive] = useState(demo);
const [spinning, setSpinning] = useState(false);
const [won, setWon] = useState(false);
const [angle, setAngle] = useState(0);
const timer = useRef<number>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(function () {
return function () {
if (timer.current !== null) {
window.clearTimeout(timer.current);
}
};
}, []);
// vanilla 의 click 핸들러와 같은 순서·같은 수치
function applySpin() {
if (spinning) {
return;
}
setLive(false);
setWon(false);
setSpinning(true);
const idx = PICKS[Math.floor(Math.random() * PICKS.length)];
const target = 1800 + (360 - (idx * PER_SLICE + PER_SLICE / 2));
setAngle(target);
timer.current = window.setTimeout(function () {
setSpinning(false);
setWon(true);
}, SETTLE_MS);
}
const cls = [
styles.root,
live ? styles.isDemo : "",
spinning ? styles.isSpinning : "",
won ? styles.isWon : "",
].filter(Boolean).join(" ");
const discStyle: CSSProperties = live ? {} : { transform: `rotate(${angle}deg)` };
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<div className={styles.rig}>
<div className={styles.pointer} aria-hidden="true"></div>
<div className={styles.disc} style={discStyle}>
<div className={styles.ring} aria-hidden="true"></div>
{SLICES.map(function (slice: Slice) {
const sliceVars: SliceVars = { "--k": slice.k };
const lblCls = [styles.lbl, slice.k % 2 === 1 ? styles.lblB : ""].filter(Boolean).join(" ");
return (
<span className={lblCls} style={sliceVars} key={slice.k}><b>{slice.text}</b></span>
);
})}
<button type="button" className={styles.hub} onClick={applySpin}>뽑기</button>
</div>
</div>
<p className={styles.result} aria-live="polite">
<span className={`${styles.msg} ${styles.msgIdle}`}>판을 돌려 할인을 뽑아보세요</span>
<span className={`${styles.msg} ${styles.msgSpin}`} aria-hidden="true">돌리는 중</span>
<span className={`${styles.msg} ${styles.msgWin}`} aria-hidden="true">30% 할인 당첨!</span>
</p>
</div>
</main>
);
}03 · Spin-to-win wheel — Measured area 32.835% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Signup welcome popups and first-launch draws.
.wh__disc {
position: relative; width: 100%; height: 100%; border-radius: 50%;
background:
conic-gradient(
$subject-cream 0deg 45deg,
#1c3f9e 45deg 90deg,
$subject-cream 90deg 135deg,
#1c3f9e 135deg 180deg,
$subject-cream 180deg 225deg,
#1c3f9e 225deg 270deg,
$subject-cream 270deg 315deg,
#1c3f9e 315deg 360deg
);
box-shadow: $shadow-raised;
transition: transform 1.8s cubic-bezier(.12, .8, .08, 1);
}
// 1800deg is exactly five revolutions — the loop lands where it started
.wh.is-demo .wh__disc { animation: whSpin 2s cubic-bezier(.12, .8, .08, 1) infinite; transition: none; }
@keyframes whSpin {
0% { transform: rotate(0deg); }
82%, 100% { transform: rotate(1800deg); }
}
04Scratch-off coupon
What you see
The foil is a real canvas, painted with a three-stop gradient and 26 random hairline cracks, and pointermove erases it for good through globalCompositeOperation set to destination-out. Every move samples the alpha channel to see how much has come off, and past 55% the remainder clears itself rather than making anyone finish the chore — 22.853% cumulative change over 19 of 23 frames.

<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>Scratch to reveal</span></p>
<button type="button" class="sc__reveal" id="revealBtn">Reveal now</button>
</div>
<div class="sc__zone" id="zone">
<div class="sc__prize">
<p class="sc__pct">30<i>%</i></p>
<p class="sc__say">Hidden discount</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">Scratch the foil</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 = 'The discount was 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 · Scratch-off coupon — Measured area 22.853% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Random reward coupons and event result cards.
// the real scratch — pointermove keeps erasing destination-out circles
var drawing = false;
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 16, 0, Math.PI * 2);
ctx.fill();
}
// how much has come off — measured from the alpha samples
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;
}
zone.addEventListener('pointermove', function (e) {
if (!drawing || scratched) { return; }
var p = pos(e);
scratchAt(p.x, p.y);
if (clearedRatio() > .55) { finish(); }
});
05Flip gift card
What happens on screen
Two faces sit stacked at inset: 0 with backface-visibility: hidden and the back pre-rotated 180deg, so one rotateY on the preserve-3d parent turns them as a single card instead of two sheets. That flip is the largest single-frame change of the nine — 33.856% of a 34.278% total — which is why the grid preview cuts between the faces rather than tweening through the edge-on view.

<main class="stage">
<div class="gc is-demo" id="gc">
<div class="gc__bob">
<div class="gc__card" id="card">
<div class="gc__face gc__face--front">
<p class="gc__brand"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="gc__ico" aria-hidden="true"><path d="M208,128v72a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V128Z" opacity="0.2"/><path d="M216,72H180.92c.39-.33.79-.65,1.17-1A29.53,29.53,0,0,0,192,49.57,32.62,32.62,0,0,0,158.44,16,29.53,29.53,0,0,0,137,25.91a54.94,54.94,0,0,0-9,14.48,54.94,54.94,0,0,0-9-14.48A29.53,29.53,0,0,0,97.56,16,32.62,32.62,0,0,0,64,49.57,29.53,29.53,0,0,0,73.91,71c.38.33.78.65,1.17,1H40A16,16,0,0,0,24,88v32a16,16,0,0,0,16,16v64a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V136a16,16,0,0,0,16-16V88A16,16,0,0,0,216,72ZM149,36.51a13.69,13.69,0,0,1,10-4.5h.49A16.62,16.62,0,0,1,176,49.08a13.69,13.69,0,0,1-4.5,10c-9.49,8.4-25.24,11.36-35,12.4C137.7,60.89,141,45.5,149,36.51Zm-64.09.36A16.63,16.63,0,0,1,96.59,32h.49a13.69,13.69,0,0,1,10,4.5c8.39,9.48,11.35,25.2,12.39,34.92-9.72-1-25.44-4-34.92-12.39a13.69,13.69,0,0,1-4.5-10A16.6,16.6,0,0,1,84.87,36.87ZM40,88h80v32H40Zm16,48h64v64H56Zm144,64H136V136h64Zm16-80H136V88h80v32Z"/></svg><span>GIFT CARD</span></p>
<p class="gc__amount">50,000 KRW</p>
<p class="gc__hint">Tap to flip</p>
</div>
<div class="gc__face gc__face--back" aria-hidden="true">
<p class="gc__balLabel">Remaining balance</p>
<p class="gc__bal">48,200 KRW</p>
<div class="gc__pinRow">
<span class="gc__pinDots" id="pinDots">PIN ••••</span>
<button type="button" class="gc__pinBtn" id="pinBtn">Show PIN</button>
</div>
</div>
</div>
</div>
<p class="gc__live" id="live" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기프트 카드 뒤집기 — rotateY + backface-visibility 로 두 판이 한 장처럼 맞물린다.
// 격자 미리보기(.is-demo)는 뒤집힘을 steps 컷으로 보여준다(회전 중간은 모서리뷰 한 줄이라 포스터가 죽는다 — 252-07 함정).
// 실제 클릭은 부드러운 transition 회전 그대로.
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
.gc { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.gc__bob { display: flex; justify-content: center; width: 100%; }
.gc__card {
position: relative; width: min(100%, 288px); aspect-ratio: 288 / 168;
transform-style: preserve-3d; cursor: pointer;
transition: transform 600ms $easing;
}
.gc__card:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 4px; border-radius: $r-card; }
.gc.is-flipped .gc__card { transform: rotateY(180deg); }
.gc__face {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: $sp-1;
border-radius: $r-card; box-shadow: $shadow-raised;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
padding: $sp-4;
}
.gc__face--front { background: $subject-lilac; color: $subject-ink; }
.gc__face--back { background: $subject-cream; color: $subject-ink; transform: rotateY(180deg); gap: $sp-2; }
.gc__brand { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: clamp(10px, 2.8vw, 12px); letter-spacing: .2em; color: rgba(23,20,26,.78); }
.gc__ico { width: 18px; height: 18px; color: $subject-ink; }
.gc__amount { margin: 0; font-weight: 800; font-size: clamp(28px, 8vw, 38px); line-height: 1; color: $subject-ink; }
.gc__hint { margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: rgba(23,20,26,.78); }
.gc__balLabel { margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: rgba(23,20,26,.72); }
.gc__bal { margin: 0; font-weight: 800; font-size: clamp(22px, 6.4vw, 28px); line-height: 1; color: $subject-blue; }
.gc__pinRow { display: flex; align-items: center; gap: $sp-2; }
.gc__pinDots { font-weight: 800; font-size: clamp(11px, 3.2vw, 13px); letter-spacing: .1em; color: $subject-ink; }
.gc__pinBtn {
border: 0; cursor: pointer; background: $subject-ink; color: $subject-cream;
border-radius: $r-pill; font-family: inherit; font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px); padding: $sp-1 $sp-3; line-height: 1.3;
}
.gc__pinBtn:focus-visible { outline: 3px solid $subject-lilac; outline-offset: 2px; }
.gc__live { margin: 0; min-height: 1.2em; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: $subject-cream; }
// ── 격자 미리보기 루프: 바깥 .gc__bob 은 살짝 둥둥(연속), .gc__card 는 steps 컷으로
// 앞면 → 뒷면 → 앞면. 회전 중간(모서리뷰)을 그리지 않는다(252-07 함정).
.gc.is-demo .gc__bob { animation: gcFloat 2s ease-in-out infinite; }
@keyframes gcFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
.gc.is-demo .gc__card { animation: gcCut 2s steps(1, end) infinite; transition: none; }
@keyframes gcCut { 0%, 30% { transform: rotateY(0deg); } 30.5%, 85% { transform: rotateY(180deg); } 85.5%, 100% { transform: rotateY(0deg); } }
.gc.is-demo .gc__pinDots { animation: gcPin 2s steps(1, end) infinite; }
@keyframes gcPin { 0%, 46% { opacity: 1; } 46.5%, 60% { opacity: .35; } 60.5%, 100% { opacity: 1; } }
@media (max-width: 340px) {
.gc__face { padding: $sp-2; }
.gc__card { width: min(100%, 232px); }
.gc { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.gc.is-demo .gc__bob,
.gc.is-demo .gc__card,
.gc.is-demo .gc__pinDots { animation: none !important; }
.gc__card { transition: none !important; }
.gc.is-demo .gc__card { transform: rotateY(180deg); }
}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);
}
.gc {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.gc__bob {
display: flex;
justify-content: center;
width: 100%;
}
.gc__card {
position: relative;
width: min(100%, 288px);
aspect-ratio: 288/168;
transform-style: preserve-3d;
cursor: pointer;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gc__card:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 4px;
border-radius: 16px;
}
.gc.is-flipped .gc__card {
transform: rotateY(180deg);
}
.gc__face {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
padding: 16px;
}
.gc__face--front {
background: #b9a0ff;
color: #17141a;
}
.gc__face--back {
background: #fff7e6;
color: #17141a;
transform: rotateY(180deg);
gap: 8px;
}
.gc__brand {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 12px);
letter-spacing: 0.2em;
color: rgba(23, 20, 26, 0.78);
}
.gc__ico {
width: 18px;
height: 18px;
color: #17141a;
}
.gc__amount {
margin: 0;
font-weight: 800;
font-size: clamp(28px, 8vw, 38px);
line-height: 1;
color: #17141a;
}
.gc__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(23, 20, 26, 0.78);
}
.gc__balLabel {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(23, 20, 26, 0.72);
}
.gc__bal {
margin: 0;
font-weight: 800;
font-size: clamp(22px, 6.4vw, 28px);
line-height: 1;
color: #2f6df6;
}
.gc__pinRow {
display: flex;
align-items: center;
gap: 8px;
}
.gc__pinDots {
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
letter-spacing: 0.1em;
color: #17141a;
}
.gc__pinBtn {
border: 0;
cursor: pointer;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
padding: 4px 12px;
line-height: 1.3;
}
.gc__pinBtn:focus-visible {
outline: 3px solid #b9a0ff;
outline-offset: 2px;
}
.gc__live {
margin: 0;
min-height: 1.2em;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: #fff7e6;
}
.gc.is-demo .gc__bob {
animation: gcFloat 2s ease-in-out infinite;
}
@keyframes gcFloat {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.gc.is-demo .gc__card {
animation: gcCut 2s steps(1, end) infinite;
transition: none;
}
@keyframes gcCut {
0%, 30% {
transform: rotateY(0deg);
}
30.5%, 85% {
transform: rotateY(180deg);
}
85.5%, 100% {
transform: rotateY(0deg);
}
}
.gc.is-demo .gc__pinDots {
animation: gcPin 2s steps(1, end) infinite;
}
@keyframes gcPin {
0%, 46% {
opacity: 1;
}
46.5%, 60% {
opacity: 0.35;
}
60.5%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.gc__face {
padding: 8px;
}
.gc__card {
width: min(100%, 232px);
}
.gc {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.gc.is-demo .gc__bob,
.gc.is-demo .gc__card,
.gc.is-demo .gc__pinDots {
animation: none !important;
}
.gc__card {
transition: none !important;
}
.gc.is-demo .gc__card {
transform: rotateY(180deg);
}
}var gc = document.getElementById('gc');
var card = document.getElementById('card');
var pinBtn = document.getElementById('pinBtn');
var pinDots = document.getElementById('pinDots');
var live = document.getElementById('live');
var pinTimer = null;
var faces = {
front: document.querySelector('.gc__face--front'),
back: document.querySelector('.gc__face--back')
};
card.addEventListener('click', function () {
gc.classList.remove('is-demo');
var flipped = gc.classList.toggle('is-flipped');
faces.back.setAttribute('aria-hidden', flipped ? 'false' : 'true');
live.textContent = flipped ? 'This is the back of the card' : 'This is the front of the card';
});
pinBtn.addEventListener('click', function (e) {
e.stopPropagation();
gc.classList.remove('is-demo');
pinDots.textContent = 'PIN 4820';
live.textContent = 'The PIN shows for 3 seconds';
window.clearTimeout(pinTimer);
pinTimer = window.setTimeout(function () {
pinDots.textContent = 'PIN ••••';
}, 3000);
});// GiftCardFlip — vanilla 판(run/283/demo/05-coupon-giftcard-flip/index.html) 의 React 포트.
// 카드를 누르면 rotateY 로 뒤집히고, PIN 보기는 3초 동안만 숫자를 드러냈다가 점으로 되돌아간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
import styles from "./GiftCardFlip.module.scss";
export type GiftCardFlipProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const PIN_MASK = "PIN ••••";
const PIN_PLAIN = "PIN 4820";
const PIN_HOLD = 3000;
export default function GiftCardFlip({
duration = 600,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
}: GiftCardFlipProps) {
const pinTimerRef = useRef<number>(0);
const [demo, setDemo] = useState(true);
const [flipped, setFlipped] = useState(false);
const [pinText, setPinText] = useState(PIN_MASK);
const [live, setLive] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 컴포넌트가 사라질 때 PIN 되돌리기 타이머를 남기지 않는다
useEffect(() => {
return () => window.clearTimeout(pinTimerRef.current ?? 0);
}, []);
function onCardClick(): void {
setDemo(false);
const next = !flipped;
setFlipped(next);
setLive(next ? "카드 뒷면이에요" : "카드 앞면이에요");
}
function onPinClick(event: ReactMouseEvent<HTMLButtonElement>): void {
event.stopPropagation();
setDemo(false);
setPinText(PIN_PLAIN);
setLive("PIN 을 3초간 보여줘요");
window.clearTimeout(pinTimerRef.current ?? 0);
pinTimerRef.current = window.setTimeout(() => {
setPinText(PIN_MASK);
}, PIN_HOLD);
}
const cls = [styles.root, demo ? styles.isDemo : "", flipped ? styles.isFlipped : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.bob}>
<div className={styles.card} onClick={onCardClick}>
<div className={`${styles.face} ${styles.front}`}>
<p className={styles.brand}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M208,128v72a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V128Z" opacity="0.2"/><path d="M216,72H180.92c.39-.33.79-.65,1.17-1A29.53,29.53,0,0,0,192,49.57,32.62,32.62,0,0,0,158.44,16,29.53,29.53,0,0,0,137,25.91a54.94,54.94,0,0,0-9,14.48,54.94,54.94,0,0,0-9-14.48A29.53,29.53,0,0,0,97.56,16,32.62,32.62,0,0,0,64,49.57,29.53,29.53,0,0,0,73.91,71c.38.33.78.65,1.17,1H40A16,16,0,0,0,24,88v32a16,16,0,0,0,16,16v64a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V136a16,16,0,0,0,16-16V88A16,16,0,0,0,216,72ZM149,36.51a13.69,13.69,0,0,1,10-4.5h.49A16.62,16.62,0,0,1,176,49.08a13.69,13.69,0,0,1-4.5,10c-9.49,8.4-25.24,11.36-35,12.4C137.7,60.89,141,45.5,149,36.51Zm-64.09.36A16.63,16.63,0,0,1,96.59,32h.49a13.69,13.69,0,0,1,10,4.5c8.39,9.48,11.35,25.2,12.39,34.92-9.72-1-25.44-4-34.92-12.39a13.69,13.69,0,0,1-4.5-10A16.6,16.6,0,0,1,84.87,36.87ZM40,88h80v32H40Zm16,48h64v64H56Zm144,64H136V136h64Zm16-80H136V88h80v32Z"/></svg>
<span>GIFT CARD</span>
</p>
<p className={styles.amount}>50,000원</p>
<p className={styles.hint}>눌러서 뒤집기</p>
</div>
<div className={`${styles.face} ${styles.back}`} aria-hidden={flipped ? "false" : "true"}>
<p className={styles.balLabel}>남은 금액</p>
<p className={styles.bal}>48,200원</p>
<div className={styles.pinRow}>
<span className={styles.pinDots}>{pinText}</span>
<button type="button" className={styles.pinBtn} onClick={onPinClick}>PIN 보기</button>
</div>
</div>
</div>
</div>
<p className={styles.live} aria-live="polite">{live}</p>
</div>
</main>
);
}05 · Flip gift card — Measured area 34.278% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Gifting pages and gift-value pickers.
.gc__card {
position: relative; width: min(100%, 288px); aspect-ratio: 288 / 168;
transform-style: preserve-3d; cursor: pointer;
transition: transform 600ms $easing;
}
.gc.is-flipped .gc__card { transform: rotateY(180deg); }
.gc__face {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: $sp-1;
border-radius: $r-card; box-shadow: $shadow-raised;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
padding: $sp-4;
}
.gc__face--back { background: $subject-cream; color: $subject-ink; transform: rotateY(180deg); gap: $sp-2; }
// the preview cuts front → back → front; the mid-rotation edge-on view kills the poster
.gc.is-demo .gc__card { animation: gcCut 2s steps(1, end) infinite; transition: none; }
@keyframes gcCut { 0%, 30% { transform: rotateY(0deg); } 30.5%, 85% { transform: rotateY(180deg); } 85.5%, 100% { transform: rotateY(0deg); } }
06Coupon stack fan
What moves
Three stacked cards read their own --i and spread by rotate plus translateX, so a hover, a keyboard focus, or one tap opens the fan and the three offers can be weighed side by side. The two back cards are deliberately lighter inks (#3a3346 and #5b5270) because near-identical dark cards still read as one card even when spread; the run changes 29.548% of the stage across 23 of 23 frames.

<main class="stage">
<div class="fan is-demo" id="fan">
<div class="fan__stack" id="stack" role="group" aria-label="Three coupons in the wallet">
<button type="button" class="fan__card fan__card--1" style="--i: 0" data-msg="First-purchase 10% lifted into checkout" aria-pressed="false">
<span class="fan__tag">New</span>
<span class="fan__pct">10<i>%</i></span>
<span class="fan__name">First-purchase discount</span>
</button>
<button type="button" class="fan__card fan__card--2" style="--i: 1" data-msg="Brand-day 20% lifted into checkout" aria-pressed="false">
<span class="fan__tag">Event</span>
<span class="fan__pct">20<i>%</i></span>
<span class="fan__name">Brand-day discount</span>
</button>
<button type="button" class="fan__card fan__card--3" style="--i: 2" data-msg="Free-shipping coupon lifted into checkout" aria-pressed="false">
<span class="fan__tag">Shipping</span>
<span class="fan__pct">FREE</span>
<span class="fan__name">Free-shipping coupon</span>
</button>
</div>
<p class="fan__live" id="live" aria-live="polite">Hover to fan out</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 쿠폰 더미 부채꼴 — 겹친 카드가 --i 순서대로 rotate + translateX 로 부채꼴로 펼쳐진다.
// 실제 조작은 :hover / :focus-within 트랜지션, 격자 미리보기(.is-demo)는 펼침·접힘 루프.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-yellow);
.fan { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; }
.fan__stack { position: relative; width: min(72%, 224px); aspect-ratio: 224 / 108; }
.fan__card {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: flex-start;
gap: 0; border: 0; cursor: pointer; border-radius: $r-card; padding: $sp-2 $sp-3;
background: $subject-ink; color: $subject-cream; font-family: inherit; text-align: left;
box-shadow: $shadow-press;
transition: transform 300ms $easing, box-shadow 300ms $easing;
}
.fan__card:focus-visible { outline: 3px solid $subject-blue; outline-offset: 3px; }
.fan__card--1 { z-index: 3; }
// 겹친 카드는 배경을 확실히 벌린다 — 비슷한 먹색끼리는 펼쳐도 몇 장인지 안 보인다(포스터 실측)
.fan__card--2 { z-index: 2; background: #3a3346; }
// 맨 뒤 장은 내용을 오른쪽 끝에 붙인다 — 왼쪽 위에 두면 앞 카드에 통째로 가린다(포스터 실측)
.fan__card--3 { z-index: 1; background: #5b5270; align-items: flex-end; text-align: right; }
.fan__tag {
background: $stage-yellow; color: $subject-ink; border-radius: $r-pill;
font-weight: 800; font-size: clamp(8px, 2.4vw, 10px); padding: 0 $sp-2; line-height: 1.6;
}
.fan__pct { margin-top: $sp-1; font-weight: 800; font-size: clamp(24px, 7vw, 34px); line-height: 1; color: $stage-yellow; }
.fan__pct i { font-style: normal; font-size: .5em; }
.fan__name { margin-top: $sp-1; font-weight: 700; font-size: clamp(10px, 3vw, 12px); color: rgba(255,247,230,.8); }
// 실제 조작: 스택에 호버·초점이 닿거나(마우스·키보드) 한 번 누르면(터치) 부채꼴로 펼침
.fan__stack:hover .fan__card--1,
.fan__stack:focus-within .fan__card--1,
.fan__stack.is-fanned .fan__card--1 { transform: rotate(-8deg) translateX(min(-34px, -9vw)) translateY(-4px); }
.fan__stack:hover .fan__card--2,
.fan__stack:focus-within .fan__card--2,
.fan__stack.is-fanned .fan__card--2 { transform: translateY(-12px); }
.fan__stack:hover .fan__card--3,
.fan__stack:focus-within .fan__card--3,
.fan__stack.is-fanned .fan__card--3 { transform: rotate(8deg) translateX(min(40px, 11vw)) translateY(8px); }
.fan__card[aria-pressed="true"] { transform: translateY(-8px); box-shadow: $shadow-raised; z-index: 4; }
// ── 격자 미리보기 루프: **펼친 상태를 유지**하고 그 안에서 한 장을 골라 들어올린다.
// 접었다 펴는 왕복을 루프에 넣으면 접히는 구간이 프레임당 변화가 가장 커서 대표 이미지가
// 늘 「겹쳐 있는 더미」가 된다(실측 3회) — 주제인 부채꼴이 안 보인다. 255-03 계열의 함정.
// 접힘→펼침 자체는 실사용 :hover / :focus-within 트랜지션으로 보여준다.
.fan.is-demo .fan__card {
transform: rotate(calc((var(--i) - 1) * 9deg)) translateX(calc((var(--i) - 1) * min(58px, 16vw))) translateY(calc(var(--i) * -4px));
}
.fan.is-demo .fan__card--1 { animation-name: fanSideL; animation-duration: 2s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; }
.fan.is-demo .fan__card--3 { animation-name: fanSideR; animation-duration: 2s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; }
@keyframes fanSideL {
0%, 100% { transform: rotate(-9deg) translateX(min(-58px, -16vw)) translateY(0px); }
50% { transform: rotate(-13deg) translateX(min(-72px, -20vw)) translateY(-4px); }
}
// 오른쪽 카드는 조금 더 벌리고 아래로 — 가운데 카드가 들리면 내용이 통째로 가린다(포스터 실측)
@keyframes fanSideR {
0%, 100% { transform: rotate(9deg) translateX(min(76px, 21vw)) translateY(12px); }
50% { transform: rotate(13deg) translateX(min(88px, 24vw)) translateY(8px); }
}
// 가운데 카드는 「이걸 고른다」로 들렸다 내려온다. 들린 동안은 맨 앞으로 온다
.fan.is-demo .fan__card--2 { animation-name: fanPick, fanPickZ; animation-duration: 2s, 2s; animation-timing-function: ease-in-out, steps(1, end); animation-iteration-count: infinite, infinite; }
@keyframes fanPickZ { 0%, 24% { z-index: 2; } 24.5%, 84% { z-index: 5; } 84.5%, 100% { z-index: 2; } }
@keyframes fanPick {
0%, 16% { transform: translateY(-4px) scale(1); }
40%, 70% { transform: translateY(-26px) scale(1.06); }
88%, 100% { transform: translateY(-4px) scale(1); }
}
.fan__live { margin: 0; min-height: 1.2em; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: $subject-ink; }
@media (max-width: 340px) {
// 부채꼴은 카드 폭 + 좌우 이동량이 전부 화면 안에 들어와야 한다(320px 실측 334px)
.fan__stack { width: 140px; }
.fan__card { padding: $sp-1 $sp-2; }
.fan { gap: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.fan.is-demo .fan__card { animation: none !important; transform: rotate(calc((var(--i) - 1) * 9deg)) translateX(calc((var(--i) - 1) * min(58px, 16vw))) translateY(calc(var(--i) * -4px)); }
.fan__card { transition: none !important; }
}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);
}
.fan {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.fan__stack {
position: relative;
width: min(72%, 224px);
aspect-ratio: 224/108;
}
.fan__card {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
border: 0;
cursor: pointer;
border-radius: 16px;
padding: 8px 12px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
text-align: left;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fan__card:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.fan__card--1 {
z-index: 3;
}
.fan__card--2 {
z-index: 2;
background: #3a3346;
}
.fan__card--3 {
z-index: 1;
background: #5b5270;
align-items: flex-end;
text-align: right;
}
.fan__tag {
background: #ffd23f;
color: #17141a;
border-radius: 999px;
font-weight: 800;
font-size: clamp(8px, 2.4vw, 10px);
padding: 0 8px;
line-height: 1.6;
}
.fan__pct {
margin-top: 4px;
font-weight: 800;
font-size: clamp(24px, 7vw, 34px);
line-height: 1;
color: #ffd23f;
}
.fan__pct i {
font-style: normal;
font-size: 0.5em;
}
.fan__name {
margin-top: 4px;
font-weight: 700;
font-size: clamp(10px, 3vw, 12px);
color: rgba(255, 247, 230, 0.8);
}
.fan__stack:hover .fan__card--1,
.fan__stack:focus-within .fan__card--1,
.fan__stack.is-fanned .fan__card--1 {
transform: rotate(-8deg) translateX(min(-34px, -9vw)) translateY(-4px);
}
.fan__stack:hover .fan__card--2,
.fan__stack:focus-within .fan__card--2,
.fan__stack.is-fanned .fan__card--2 {
transform: translateY(-12px);
}
.fan__stack:hover .fan__card--3,
.fan__stack:focus-within .fan__card--3,
.fan__stack.is-fanned .fan__card--3 {
transform: rotate(8deg) translateX(min(40px, 11vw)) translateY(8px);
}
.fan__card[aria-pressed=true] {
transform: translateY(-8px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 4;
}
.fan.is-demo .fan__card {
transform: rotate(calc((var(--i) - 1) * 9deg)) translateX(calc((var(--i) - 1) * min(58px, 16vw))) translateY(calc(var(--i) * -4px));
}
.fan.is-demo .fan__card--1 {
animation-name: fanSideL;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.fan.is-demo .fan__card--3 {
animation-name: fanSideR;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes fanSideL {
0%, 100% {
transform: rotate(-9deg) translateX(min(-58px, -16vw)) translateY(0px);
}
50% {
transform: rotate(-13deg) translateX(min(-72px, -20vw)) translateY(-4px);
}
}
@keyframes fanSideR {
0%, 100% {
transform: rotate(9deg) translateX(min(76px, 21vw)) translateY(12px);
}
50% {
transform: rotate(13deg) translateX(min(88px, 24vw)) translateY(8px);
}
}
.fan.is-demo .fan__card--2 {
animation-name: fanPick, fanPickZ;
animation-duration: 2s, 2s;
animation-timing-function: ease-in-out, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes fanPickZ {
0%, 24% {
z-index: 2;
}
24.5%, 84% {
z-index: 5;
}
84.5%, 100% {
z-index: 2;
}
}
@keyframes fanPick {
0%, 16% {
transform: translateY(-4px) scale(1);
}
40%, 70% {
transform: translateY(-26px) scale(1.06);
}
88%, 100% {
transform: translateY(-4px) scale(1);
}
}
.fan__live {
margin: 0;
min-height: 1.2em;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: #17141a;
}
@media (max-width: 340px) {
.fan__stack {
width: 140px;
}
.fan__card {
padding: 4px 8px;
}
.fan {
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.fan.is-demo .fan__card {
animation: none !important;
transform: rotate(calc((var(--i) - 1) * 9deg)) translateX(calc((var(--i) - 1) * min(58px, 16vw))) translateY(calc(var(--i) * -4px));
}
.fan__card {
transition: none !important;
}
}var fan = document.getElementById('fan');
var stack = document.getElementById('stack');
var live = document.getElementById('live');
var cards = Array.prototype.slice.call(document.querySelectorAll('.fan__card'));
// 호버가 없는 터치 기기에서는 겹친 뒤 카드를 영영 누를 수 없다 — 첫 탭이 더미를 펼친다
stack.addEventListener('pointerdown', function () {
fan.classList.remove('is-demo');
stack.classList.add('is-fanned');
});
cards.forEach(function (c) {
c.addEventListener('click', function () {
fan.classList.remove('is-demo');
var on = c.getAttribute('aria-pressed') === 'true';
cards.forEach(function (o) { o.setAttribute('aria-pressed', 'false'); });
c.setAttribute('aria-pressed', on ? 'false' : 'true');
live.textContent = on ? 'Selection cleared' : c.getAttribute('data-msg');
});
});// CouponStackFan — vanilla 판(run/283/demo/06-coupon-stack-fan/index.html) 의 React 포트.
// 겹친 쿠폰 세 장이 --i 순서대로 부채꼴로 펼쳐지고, 한 장을 고르면 그 장만 위로 들린다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CouponStackFan.module.scss";
export type CouponStackFanProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CardVars = CSSProperties & { "--i"?: number };
type FanCard = {
id: string;
tag: string;
/** 큰 숫자 — unit 이 있으면 작은 글씨 단위가 뒤에 붙는다 */
amount: string;
unit: string;
name: string;
message: string;
};
const CARDS: FanCard[] = [
{ id: "first", tag: "신규", amount: "10", unit: "%", name: "첫 구매 할인", message: "첫 구매 10%을 결제에 올렸어요" },
{ id: "brandday", tag: "이벤트", amount: "20", unit: "%", name: "브랜드데이 할인", message: "브랜드데이 20%을 결제에 올렸어요" },
{ id: "shipping", tag: "배송", amount: "0원", unit: "", name: "무료배송 쿠폰", message: "무료배송 쿠폰을 결제에 올렸어요" },
];
const ORDER_CLASS = [styles.card1, styles.card2, styles.card3];
export default function CouponStackFan({
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: CouponStackFanProps) {
const [demo, setDemo] = useState(true);
const [picked, setPicked] = useState(-1);
const [live, setLive] = useState("호버하면 펼쳐져요");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 장만 눌린 상태를 유지한다 — 눌린 장을 다시 누르면 해제
function applyPick(index: number): void {
setDemo(false);
const on = picked === index;
setPicked(on ? -1 : index);
setLive(on ? "선택을 해제했어요" : CARDS[index].message);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.stack} role="group" aria-label="보관함 쿠폰 세 장">
{CARDS.map((card, i) => {
const cardVars: CardVars = { ["--i" as string]: i };
return (
<button
key={card.id}
type="button"
className={`${styles.card} ${ORDER_CLASS[i]}`}
style={cardVars}
aria-pressed={picked === i ? "true" : "false"}
onClick={() => applyPick(i)}
>
<span className={styles.tag}>{card.tag}</span>
<span className={styles.pct}>{card.amount}{card.unit ? <i>{card.unit}</i> : null}</span>
<span className={styles.name}>{card.name}</span>
</button>
);
})}
</div>
<p className={styles.live} aria-live="polite">{live}</p>
</div>
</main>
);
}06 · Coupon stack fan — Measured area 29.548% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Wallet screens and the picker just before checkout.
// hover, keyboard focus, or one tap on the stack opens the fan
.fan__stack:hover .fan__card--1,
.fan__stack:focus-within .fan__card--1,
.fan__stack.is-fanned .fan__card--1 { transform: rotate(-8deg) translateX(min(-34px, -9vw)) translateY(-4px); }
.fan__stack:hover .fan__card--3,
.fan__stack:focus-within .fan__card--3,
.fan__stack.is-fanned .fan__card--3 { transform: rotate(8deg) translateX(min(40px, 11vw)) translateY(8px); }
// the preview holds the spread state and lifts one card out of it
.fan.is-demo .fan__card {
transform: rotate(calc((var(--i) - 1) * 9deg)) translateX(calc((var(--i) - 1) * min(58px, 16vw))) translateY(calc(var(--i) * -4px));
}
@keyframes fanPick {
0%, 16% { transform: translateY(-4px) scale(1); }
40%, 70% { transform: translateY(-26px) scale(1.06); }
88%, 100% { transform: translateY(-4px) scale(1); }
}
07Barcode scan coupon
What you see
The bars are two repeating-linear-gradient layers, one on a 4px period and one on 9px, offset 3px from each other — enough irregularity to read as a barcode with no image file and no font. The beam travels translateY(60px) in 800ms and the paid state cuts in behind it, the second-quietest run of the set behind the ticket at 12.737% over 15 of 23 frames.

<main class="stage">
<div class="bc is-demo" id="bc">
<div class="bc__card" id="card">
<div class="bc__head">
<p class="bc__title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="bc__ico" aria-hidden="true"><path d="M224,48V208H32V48Z" opacity="0.2"/><path d="M232,48V88a8,8,0,0,1-16,0V56H184a8,8,0,0,1,0-16h40A8,8,0,0,1,232,48ZM72,200H40V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H72a8,8,0,0,0,0-16Zm152-40a8,8,0,0,0-8,8v32H184a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,224,160ZM32,96a8,8,0,0,0,8-8V56H72a8,8,0,0,0,0-16H32a8,8,0,0,0-8,8V88A8,8,0,0,0,32,96ZM80,80a8,8,0,0,0-8,8v80a8,8,0,0,0,16,0V88A8,8,0,0,0,80,80Zm104,88V88a8,8,0,0,0-16,0v80a8,8,0,0,0,16,0ZM144,80a8,8,0,0,0-8,8v80a8,8,0,0,0,16,0V88A8,8,0,0,0,144,80Zm-32,0a8,8,0,0,0-8,8v80a8,8,0,0,0,16,0V88A8,8,0,0,0,112,80Z"/></svg><span>Membership barcode</span></p>
<span class="bc__state" id="stateTxt">Waiting</span>
</div>
<div class="bc__bars" aria-hidden="true">
<span class="bc__beam"></span>
</div>
<p class="bc__digits">8842 0071 3390</p>
<div class="bc__foot">
<button type="button" class="bc__btn" id="scanBtn">Scan</button>
<p class="bc__done" id="done"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="bc__tick" aria-hidden="true"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg><span>Paid</span></p>
</div>
</div>
<p class="bc__live" id="live" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 바코드 스캔 쿠폰 — 바코드는 repeating-linear-gradient 두 겹(주기 4px·9px)로 불규칙한 막대를 만들고,
// 스캔 빔이 translateY 로 훑고 지나간 뒤 결제 완료 상태로 컷 전환한다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.bc { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.bc__card {
width: min(100%, 264px); background: $subject-cream; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3 $sp-4; border: 2px solid transparent;
}
.bc__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.bc__title { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: clamp(11px, 3.2vw, 13px); color: $subject-ink; }
.bc__ico { width: 16px; height: 16px; color: $subject-ink; }
.bc__state {
background: rgba(23,20,26,.08); color: $subject-ink; border-radius: $r-pill;
font-weight: 700; font-size: clamp(9px, 2.6vw, 10px); padding: 0 $sp-2; line-height: 1.6;
}
.bc__bars {
position: relative; margin-top: $sp-2; height: 64px; border-radius: $r-sm; overflow: hidden;
background:
repeating-linear-gradient(90deg, $subject-ink 0 2px, transparent 2px 4px),
repeating-linear-gradient(90deg, $subject-ink 0 3px, transparent 3px 9px);
background-position: 0 0, 3px 0;
}
.bc__beam {
position: absolute; left: 0; right: 0; top: 0; height: 4px; visibility: hidden;
background: linear-gradient(180deg, transparent, rgba(76,212,166,.95) 40%, rgba(23,20,26,.9) 55%, transparent);
box-shadow: 0 0 8px rgba(76,212,166,.8);
}
.bc.is-scanning .bc__beam { visibility: visible; animation: bcBeamOnce 800ms ease-in-out; }
@keyframes bcBeamOnce { 0% { transform: translateY(0); } 100% { transform: translateY(60px); } }
.bc__digits { margin: $sp-2 0 0; font-weight: 800; font-size: clamp(14px, 4.2vw, 18px); letter-spacing: .3em; color: $subject-ink; text-align: center; }
.bc__foot { position: relative; display: flex; justify-content: center; margin-top: $sp-2; min-height: 30px; }
.bc__btn {
border: 0; cursor: pointer; background: $subject-ink; color: $subject-cream;
border-radius: $r-pill; font-family: inherit; font-weight: 800;
font-size: clamp(11px, 3vw, 12px); padding: $sp-1 $sp-5; line-height: 1.4;
transition: transform 140ms cubic-bezier(.2,.8,.2,1);
}
.bc__btn:active { transform: scale(.94); }
.bc__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.bc__done { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: $sp-1; margin: 0; visibility: hidden; }
.bc.is-done .bc__done { visibility: visible; }
.bc.is-done .bc__btn { visibility: hidden; }
.bc.is-done .bc__bars { opacity: .3; }
.bc.is-done .bc__digits { text-decoration: line-through; color: rgba(23,20,26,.4); }
.bc__tick { width: 14px; height: 14px; color: #479074; }
.bc__done span { font-weight: 800; font-size: clamp(11px, 3vw, 12px); color: #479074; }
.bc__live { margin: 0; min-height: 1.2em; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: $subject-cream; }
// ── 격자 미리보기 루프: 빔이 위→아래로 훑고(10~68%) 74% 결제 완료 컷 → 94% 복귀
.bc.is-demo .bc__beam { visibility: visible; animation: bcBeamLoop 2s ease-in-out infinite; }
@keyframes bcBeamLoop {
0%, 10% { transform: translateY(0); }
68% { transform: translateY(60px); }
68.5%, 93% { transform: translateY(60px); opacity: 0; }
93.5%, 100% { transform: translateY(0); opacity: 1; }
}
.bc.is-demo .bc__btn { animation: bcBtnCut 2s steps(1, end) infinite; }
@keyframes bcBtnCut { 0%, 73% { visibility: visible; } 73.5%, 93% { visibility: hidden; } 93.5%, 100% { visibility: visible; } }
.bc.is-demo .bc__done { visibility: visible; }
.bc.is-demo .bc__done { animation: bcDonePop 2s ease-out infinite; }
@keyframes bcDonePop { 0%, 73% { transform: scale(0); } 77%, 92% { transform: scale(1); } 94%, 100% { transform: scale(0); } }
.bc.is-demo .bc__bars { animation: bcBarsCut 2s steps(1, end) infinite; }
@keyframes bcBarsCut { 0%, 73% { opacity: 1; } 73.5%, 93% { opacity: .3; } 93.5%, 100% { opacity: 1; } }
.bc.is-demo .bc__digits { animation: bcDigitsCut 2s steps(1, end) infinite; }
@keyframes bcDigitsCut { 0%, 73% { opacity: 1; } 73.5%, 93% { opacity: .4; } 93.5%, 100% { opacity: 1; } }
@media (max-width: 340px) {
.bc__card { padding: $sp-2 $sp-3; }
.bc__bars { height: 40px; margin-top: $sp-1; }
.bc__digits { margin-top: $sp-1; }
.bc__foot { margin-top: $sp-1; min-height: 26px; }
.bc { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.bc.is-demo .bc__beam,
.bc.is-demo .bc__btn,
.bc.is-demo .bc__done,
.bc.is-demo .bc__bars,
.bc.is-demo .bc__digits { animation: none !important; }
.bc__btn { transition: none !important; }
.bc.is-demo .bc__beam { transform: translateY(28px); }
.bc.is-demo .bc__done { transform: scale(1); }
.bc.is-demo .bc__btn { visibility: hidden; }
.bc.is-demo .bc__bars { opacity: .3; }
.bc.is-demo .bc__digits { opacity: .4; }
}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);
}
.bc {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.bc__card {
width: min(100%, 264px);
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px;
border: 2px solid transparent;
}
.bc__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.bc__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
color: #17141a;
}
.bc__ico {
width: 16px;
height: 16px;
color: #17141a;
}
.bc__state {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
border-radius: 999px;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 10px);
padding: 0 8px;
line-height: 1.6;
}
.bc__bars {
position: relative;
margin-top: 8px;
height: 64px;
border-radius: 8px;
overflow: hidden;
background: repeating-linear-gradient(90deg, #17141a 0 2px, transparent 2px 4px), repeating-linear-gradient(90deg, #17141a 0 3px, transparent 3px 9px);
background-position: 0 0, 3px 0;
}
.bc__beam {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 4px;
visibility: hidden;
background: linear-gradient(180deg, transparent, rgba(76, 212, 166, 0.95) 40%, rgba(23, 20, 26, 0.9) 55%, transparent);
box-shadow: 0 0 8px rgba(76, 212, 166, 0.8);
}
.bc.is-scanning .bc__beam {
visibility: visible;
animation: bcBeamOnce 800ms ease-in-out;
}
@keyframes bcBeamOnce {
0% {
transform: translateY(0);
}
100% {
transform: translateY(60px);
}
}
.bc__digits {
margin: 8px 0 0;
font-weight: 800;
font-size: clamp(14px, 4.2vw, 18px);
letter-spacing: 0.3em;
color: #17141a;
text-align: center;
}
.bc__foot {
position: relative;
display: flex;
justify-content: center;
margin-top: 8px;
min-height: 30px;
}
.bc__btn {
border: 0;
cursor: pointer;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
padding: 4px 20px;
line-height: 1.4;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bc__btn:active {
transform: scale(0.94);
}
.bc__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.bc__done {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin: 0;
visibility: hidden;
}
.bc.is-done .bc__done {
visibility: visible;
}
.bc.is-done .bc__btn {
visibility: hidden;
}
.bc.is-done .bc__bars {
opacity: 0.3;
}
.bc.is-done .bc__digits {
text-decoration: line-through;
color: rgba(23, 20, 26, 0.4);
}
.bc__tick {
width: 14px;
height: 14px;
color: #479074;
}
.bc__done span {
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
color: #479074;
}
.bc__live {
margin: 0;
min-height: 1.2em;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: #fff7e6;
}
.bc.is-demo .bc__beam {
visibility: visible;
animation: bcBeamLoop 2s ease-in-out infinite;
}
@keyframes bcBeamLoop {
0%, 10% {
transform: translateY(0);
}
68% {
transform: translateY(60px);
}
68.5%, 93% {
transform: translateY(60px);
opacity: 0;
}
93.5%, 100% {
transform: translateY(0);
opacity: 1;
}
}
.bc.is-demo .bc__btn {
animation: bcBtnCut 2s steps(1, end) infinite;
}
@keyframes bcBtnCut {
0%, 73% {
visibility: visible;
}
73.5%, 93% {
visibility: hidden;
}
93.5%, 100% {
visibility: visible;
}
}
.bc.is-demo .bc__done {
visibility: visible;
}
.bc.is-demo .bc__done {
animation: bcDonePop 2s ease-out infinite;
}
@keyframes bcDonePop {
0%, 73% {
transform: scale(0);
}
77%, 92% {
transform: scale(1);
}
94%, 100% {
transform: scale(0);
}
}
.bc.is-demo .bc__bars {
animation: bcBarsCut 2s steps(1, end) infinite;
}
@keyframes bcBarsCut {
0%, 73% {
opacity: 1;
}
73.5%, 93% {
opacity: 0.3;
}
93.5%, 100% {
opacity: 1;
}
}
.bc.is-demo .bc__digits {
animation: bcDigitsCut 2s steps(1, end) infinite;
}
@keyframes bcDigitsCut {
0%, 73% {
opacity: 1;
}
73.5%, 93% {
opacity: 0.4;
}
93.5%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.bc__card {
padding: 8px 12px;
}
.bc__bars {
height: 40px;
margin-top: 4px;
}
.bc__digits {
margin-top: 4px;
}
.bc__foot {
margin-top: 4px;
min-height: 26px;
}
.bc {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc.is-demo .bc__beam,
.bc.is-demo .bc__btn,
.bc.is-demo .bc__done,
.bc.is-demo .bc__bars,
.bc.is-demo .bc__digits {
animation: none !important;
}
.bc__btn {
transition: none !important;
}
.bc.is-demo .bc__beam {
transform: translateY(28px);
}
.bc.is-demo .bc__done {
transform: scale(1);
}
.bc.is-demo .bc__btn {
visibility: hidden;
}
.bc.is-demo .bc__bars {
opacity: 0.3;
}
.bc.is-demo .bc__digits {
opacity: 0.4;
}
}var bc = document.getElementById('bc');
var scanBtn = document.getElementById('scanBtn');
var stateTxt = document.getElementById('stateTxt');
var live = document.getElementById('live');
var bars = document.querySelector('.bc__bars');
scanBtn.addEventListener('click', function () {
bc.classList.remove('is-demo');
if (bc.classList.contains('is-done')) { return; }
bc.classList.add('is-scanning');
stateTxt.textContent = 'Scanning';
window.setTimeout(function () {
bc.classList.remove('is-scanning');
bc.classList.add('is-done');
stateTxt.textContent = 'Paid';
live.textContent = 'The barcode was read and checkout completed';
}, 800);
});// BarcodeScanCoupon — vanilla 판(run/283/demo/07-coupon-barcode-scan/index.html) 의 React 포트.
// 스캔 버튼을 누르면 미리보기 루프에서 빠져나와 빔이 한 번 훑고(800ms) 결제 완료로 컷 전환한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BarcodeScanCoupon.module.scss";
export type BarcodeScanCouponProps = {
/** 격자 미리보기 루프 한 바퀴 (vanilla 2s) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 빔이 바코드를 훑는 시간 — scss 의 bcBeamOnce 800ms 와 같은 값 */
const SCAN_MS = 800;
export default function BarcodeScanCoupon({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: BarcodeScanCouponProps) {
const [demo, setDemo] = useState(true);
const [scanning, setScanning] = useState(false);
const [done, setDone] = useState(false);
const [stateText, setStateText] = useState("대기 중");
const [liveText, setLiveText] = useState("");
const timerRef = useRef<number>(null);
useEffect(() => {
return () => {
if (timerRef.current !== null) { window.clearTimeout(timerRef.current); }
};
}, []);
function applyScan(): void {
setDemo(false);
if (done) { return; }
setScanning(true);
setStateText("스캔 중");
timerRef.current = window.setTimeout(() => {
setScanning(false);
setDone(true);
setStateText("결제 완료");
setLiveText("바코드를 읽어 결제를 끝냈어요");
}, SCAN_MS);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls: string = [
styles.root,
demo ? styles.isDemo : "",
scanning ? styles.isScanning : "",
done ? styles.isDone : "",
].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="M224,48V208H32V48Z" opacity="0.2" /><path d="M232,48V88a8,8,0,0,1-16,0V56H184a8,8,0,0,1,0-16h40A8,8,0,0,1,232,48ZM72,200H40V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H72a8,8,0,0,0,0-16Zm152-40a8,8,0,0,0-8,8v32H184a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,224,160ZM32,96a8,8,0,0,0,8-8V56H72a8,8,0,0,0,0-16H32a8,8,0,0,0-8,8V88A8,8,0,0,0,32,96ZM80,80a8,8,0,0,0-8,8v80a8,8,0,0,0,16,0V88A8,8,0,0,0,80,80Zm104,88V88a8,8,0,0,0-16,0v80a8,8,0,0,0,16,0ZM144,80a8,8,0,0,0-8,8v80a8,8,0,0,0,16,0V88A8,8,0,0,0,144,80Zm-32,0a8,8,0,0,0-8,8v80a8,8,0,0,0,16,0V88A8,8,0,0,0,112,80Z" /></svg>
<span>멤버십 바코드</span>
</p>
<span className={styles.state}>{stateText}</span>
</div>
<div className={styles.bars} aria-hidden="true">
<span className={styles.beam} />
</div>
<p className={styles.digits}>8842 0071 3390</p>
<div className={styles.foot}>
<button type="button" className={styles.btn} onClick={applyScan}>스캔</button>
<p className={styles.done}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.tick} aria-hidden="true"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2" /><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z" /></svg>
<span>결제 완료</span>
</p>
</div>
</div>
<p className={styles.live} aria-live="polite">{liveText}</p>
</div>
</main>
);
}07 · Barcode scan coupon — Measured area 12.737% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
In-store payment screens and membership cards.
.bc__bars {
position: relative; margin-top: $sp-2; height: 64px; border-radius: $r-sm; overflow: hidden;
background:
repeating-linear-gradient(90deg, $subject-ink 0 2px, transparent 2px 4px),
repeating-linear-gradient(90deg, $subject-ink 0 3px, transparent 3px 9px);
background-position: 0 0, 3px 0;
}
.bc__beam {
position: absolute; left: 0; right: 0; top: 0; height: 4px; visibility: hidden;
background: linear-gradient(180deg, transparent, rgba(76,212,166,.95) 40%, rgba(23,20,26,.9) 55%, transparent);
box-shadow: 0 0 8px rgba(76,212,166,.8);
}
.bc.is-scanning .bc__beam { visibility: visible; animation: bcBeamOnce 800ms ease-in-out; }
@keyframes bcBeamOnce { 0% { transform: translateY(0); } 100% { transform: translateY(60px); } }
08Birthday candle coupon
What happens on screen
The flame is a 10×16px blob with an asymmetric border-radius and transform-origin: 50% 100%, so skewX and scaleY make it lean from its base the way a flame leans off its wick rather than slide sideways. A radial-gradient room layer warms the whole card while it burns and falls to zero the instant it is blown out, which is the moment the discount is locked in; the run measures 22.044% with a 22.035% peak frame.

<main class="stage">
<div class="bd is-demo" id="bd">
<div class="bd__card" id="card">
<div class="bd__closed">
<p class="bd__closedTitle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="bd__ico" aria-hidden="true"><path d="M104,48c0-24,24-40,24-40s24,16,24,40a24,24,0,0,1-48,0ZM208,96H48a16,16,0,0,0-16,16v23.33c0,17.44,13.67,32.18,31.1,32.66A32,32,0,0,0,96,136a32,32,0,0,0,64,0,32,32,0,0,0,32.9,32c17.43-.48,31.1-15.22,31.1-32.66V112A16,16,0,0,0,208,96Z" opacity="0.2"/><path d="M232,112a24,24,0,0,0-24-24H136V79a32.06,32.06,0,0,0,24-31c0-28-26.44-45.91-27.56-46.66a8,8,0,0,0-8.88,0C122.44,2.09,96,20,96,48a32.06,32.06,0,0,0,24,31v9H48a24,24,0,0,0-24,24v23.33a40.84,40.84,0,0,0,8,24.24V200a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V159.57a40.84,40.84,0,0,0,8-24.24ZM112,48c0-13.57,10-24.46,16-29.79,6,5.33,16,16.22,16,29.79a16,16,0,0,1-32,0ZM40,112a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8v23.33c0,13.25-10.46,24.31-23.32,24.66A24,24,0,0,1,168,136a8,8,0,0,0-16,0,24,24,0,0,1-48,0,8,8,0,0,0-16,0,24,24,0,0,1-24.68,24C50.46,159.64,40,148.58,40,135.33Zm160,96H56a8,8,0,0,1-8-8V172.56A38.77,38.77,0,0,0,62.88,176a39.69,39.69,0,0,0,29-11.31A40.36,40.36,0,0,0,96,160a40,40,0,0,0,64,0,40.36,40.36,0,0,0,4.13,4.67A39.67,39.67,0,0,0,192,176c.38,0,.76,0,1.14,0A38.77,38.77,0,0,0,208,172.56V200A8,8,0,0,1,200,208Z"/></svg><span>Happy birthday!</span></p>
<button type="button" class="bd__openBtn" id="openBtn">Untie the ribbon</button>
</div>
<div class="bd__open" aria-hidden="true">
<span class="bd__room" aria-hidden="true"></span>
<div class="bd__candle">
<span class="bd__flame" aria-hidden="true"></span>
<span class="bd__glow" aria-hidden="true"></span>
<span class="bd__wax" aria-hidden="true"></span>
</div>
<p class="bd__say">Make a wish and blow out the candle</p>
<p class="bd__pct">Birthday <b>20%</b></p>
<button type="button" class="bd__blowBtn" id="blowBtn">Blow out</button>
</div>
</div>
<p class="bd__live" id="live" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 생일 쿠폰 — 리본을 풀면(clip-path 개봉) 촛불이 나타나고, 불꽃은 아래 고정축(transform-origin)으로 흔들린다.
// 불 끄기 → 불꽃이 스르르 줄어들고(역시 아래축) 연기 오르며 할인 확정.
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-paper);
.bd { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.bd__card {
position: relative; width: min(100%, 268px); aspect-ratio: 268 / 176;
background: $subject-cream; border-radius: $r-card; box-shadow: $shadow-raised; overflow: hidden;
}
.bd__closed, .bd__open { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: $sp-2; }
.bd__closed { background: linear-gradient(160deg, $subject-pink, #ebd6dc); }
.bd__closedTitle { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: clamp(15px, 4.6vw, 20px); color: $subject-ink; }
.bd__ico { width: 22px; height: 22px; color: $subject-ink; }
.bd__openBtn {
border: 0; cursor: pointer; background: $subject-ink; color: $subject-cream;
border-radius: $r-pill; font-family: inherit; font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px); padding: $sp-2 $sp-5; line-height: 1.2;
transition: transform 140ms cubic-bezier(.2,.8,.2,1);
}
.bd__openBtn:active { transform: scale(.94); }
.bd__openBtn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.bd__open { background: $subject-cream; clip-path: inset(0 0 100% 0); }
.bd.is-open .bd__open { clip-path: inset(0 0 0 0); transition: clip-path 600ms $ease-out; }
.bd.is-open .bd__closed { visibility: hidden; }
.bd__candle { position: relative; width: 24px; height: 64px; }
.bd__wax {
position: absolute; left: 50%; bottom: 0; transform: translateX(-50%);
width: 18px; height: 44px; border-radius: $r-xs $r-xs 0 0; background: $subject-lilac;
}
.bd__flame {
position: absolute; left: calc(50% - 5px); bottom: 42px;
width: 10px; height: 16px; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
background: radial-gradient(circle at 50% 78%, #fff3c4, #d1bda1 62%, #d1ada1 100%);
transform-origin: 50% 100%;
}
.bd__glow {
position: absolute; left: calc(50% - 13px); bottom: 32px;
width: 26px; height: 26px; border-radius: 50%;
background: radial-gradient(circle, rgba(255,176,58,.5), transparent 70%);
filter: blur(4px);
}
// 촛불이 방 전체를 물들인다 — 불이 켜진 동안만 카드 바탕이 따뜻해진다
.bd__room {
position: absolute; inset: 0; pointer-events: none; opacity: 0;
background: radial-gradient(circle at 50% 42%, rgba(255,176,58,.55), rgba(255,109,58,.22) 46%, transparent 74%);
}
.bd.is-lit-off .bd__room { opacity: 0 !important; animation: none !important; }
.bd__say { margin: 0; font-weight: 700; font-size: clamp(10px, 3vw, 12px); color: rgba(23,20,26,.78); }
.bd__pct { margin: 0; font-weight: 700; font-size: clamp(13px, 4vw, 16px); color: $subject-ink; }
.bd__pct b { font-weight: 800; font-size: 1.6em; color: $subject-blue; }
.bd__blowBtn {
border: 0; cursor: pointer; background: $subject-pink; color: $subject-ink;
border-radius: $r-pill; font-family: inherit; font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px); padding: $sp-1 $sp-4; line-height: 1.3;
transition: transform 140ms cubic-bezier(.2,.8,.2,1);
}
.bd__blowBtn:active { transform: scale(.94); }
.bd__blowBtn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.bd.is-lit-off .bd__flame { animation: none; transform: scaleY(0); }
.bd.is-lit-off .bd__glow { animation: none; opacity: 0; }
.bd.is-lit-off .bd__say { visibility: hidden; }
.bd__live { margin: 0; min-height: 1.2em; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: rgba(23,20,26,.6); }
// 불꽃 흔들림 — 실사용에서도 계속 산다(장식이 아니라 상태 표시)
.bd__flame { animation: bdFlicker 900ms ease-in-out infinite; }
@keyframes bdFlicker {
0%, 100% { transform: skewX(0deg) scaleY(1) scaleX(1); }
25% { transform: skewX(4deg) scaleY(1.08) scaleX(.94); }
55% { transform: skewX(-3deg) scaleY(.94) scaleX(1.05); }
80% { transform: skewX(2deg) scaleY(1.05) scaleX(.97); }
}
.bd__glow { animation: bdGlow 900ms ease-in-out infinite; }
@keyframes bdGlow { 0%, 100% { transform: scale(1); opacity: .9; } 50% { transform: scale(1.25); opacity: .55; } }
// ── 격자 미리보기 루프: 카드는 **열린 채로** 시작한다. 개봉 wipe 를 루프에 넣으면
// 그 중간 프레임이 언제나 최대 변화라 대표 이미지가 닫힌 분홍 카드가 된다(실측) —
// 주인공인 촛불이 안 보인다. 점화(10%) → 흔들림 → 62% 불 끄기 → 70% 할인 확정 → 94% 복귀
.bd.is-demo .bd__open { clip-path: inset(0 0 0 0); }
.bd.is-demo .bd__closed { visibility: hidden; }
.bd.is-demo .bd__flame { animation: bdFlameLoop 2s ease-in-out infinite; }
@keyframes bdFlameLoop {
0%, 10% { transform: scaleY(0); }
16% { transform: scaleY(1); }
22% { transform: skewX(4deg) scaleY(1.08) scaleX(.94); }
30% { transform: skewX(-3deg) scaleY(.94) scaleX(1.05); }
38% { transform: skewX(2deg) scaleY(1.05) scaleX(.97); }
46% { transform: skewX(-4deg) scaleY(1.1) scaleX(.93); }
54% { transform: skewX(3deg) scaleY(.96) scaleX(1.04); }
62% { transform: skewX(0deg) scaleY(1); }
67%, 92% { transform: scaleY(0); }
92.5%, 100% { transform: scaleY(0); }
}
.bd.is-demo .bd__glow { animation: bdGlowLoop 2s ease-in-out infinite; }
@keyframes bdGlowLoop {
0%, 16% { transform: scale(.6); opacity: 0; }
22% { transform: scale(1); opacity: .9; }
40% { transform: scale(1.2); opacity: .6; }
56% { transform: scale(1.05); opacity: .85; }
62%, 92% { transform: scale(.4); opacity: 0; }
92.5%, 100% { transform: scale(.4); opacity: 0; }
}
// 점화는 한 프레임 컷 — 방이 밝아지는 그 장이 대표 이미지(poster)가 되게 한다.
// 서서히 밝히면 최대 변화 프레임이 「아직 어두운 중간」 이라 촛불빛이 안 찍힌다(실측).
.bd.is-demo .bd__room { animation: bdRoom 2s infinite; }
@keyframes bdRoom {
0%, 13% { opacity: 0; animation-timing-function: steps(1, end); }
14%, 40% { opacity: .95; animation-timing-function: ease-in-out; }
52% { opacity: .7; }
60% { opacity: .95; animation-timing-function: steps(1, end); }
62%, 100% { opacity: 0; }
}
// 불이 켜져 있는 동안은 실사용에서도 방이 밝다
.bd.is-open:not(.is-lit-off) .bd__room { opacity: .85; animation: bdRoomLive 900ms ease-in-out infinite; }
@keyframes bdRoomLive { 0%, 100% { opacity: .85; } 50% { opacity: .62; } }
.bd.is-demo .bd__pct { animation: bdPop 2s ease-out infinite; }
@keyframes bdPop { 0%, 68% { transform: scale(.6); opacity: 0; } 74%, 92% { transform: scale(1); opacity: 1; } 94%, 100% { transform: scale(.6); opacity: 0; } }
.bd.is-demo .bd__say { animation: bdSayCut 2s steps(1, end) infinite; }
@keyframes bdSayCut { 0%, 62% { visibility: visible; } 62.5%, 92% { visibility: hidden; } 92.5%, 100% { visibility: visible; } }
@media (max-width: 340px) {
.bd__candle { height: 44px; width: 20px; }
.bd__wax { height: 28px; width: 14px; }
.bd__flame { bottom: 26px; }
.bd__glow { bottom: 16px; }
.bd__card { width: min(100%, 228px); }
.bd__closed, .bd__open { gap: $sp-1; }
.bd { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.bd.is-demo .bd__flame,
.bd.is-demo .bd__glow,
.bd.is-demo .bd__room,
.bd.is-demo .bd__pct,
.bd.is-demo .bd__say,
.bd.is-open:not(.is-lit-off) .bd__room,
.bd__flame,
.bd__glow { animation: none !important; }
.bd.is-demo .bd__room { opacity: .85; }
.bd__openBtn, .bd__blowBtn { transition: none !important; }
.bd.is-demo .bd__flame { transform: scaleY(1); }
.bd.is-demo .bd__glow { transform: scale(1); opacity: .8; }
.bd.is-demo .bd__pct { transform: scale(1); opacity: 1; }
.bd.is-demo .bd__say { visibility: hidden; }
}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);
}
.bd {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.bd__card {
position: relative;
width: min(100%, 268px);
aspect-ratio: 268/176;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.bd__closed, .bd__open {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.bd__closed {
background: linear-gradient(160deg, #ff9ebb, #ebd6dc);
}
.bd__closedTitle {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.6vw, 20px);
color: #17141a;
}
.bd__ico {
width: 22px;
height: 22px;
color: #17141a;
}
.bd__openBtn {
border: 0;
cursor: pointer;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
padding: 8px 20px;
line-height: 1.2;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bd__openBtn:active {
transform: scale(0.94);
}
.bd__openBtn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.bd__open {
background: #fff7e6;
clip-path: inset(0 0 100% 0);
}
.bd.is-open .bd__open {
clip-path: inset(0 0 0 0);
transition: clip-path 600ms ease-out;
}
.bd.is-open .bd__closed {
visibility: hidden;
}
.bd__candle {
position: relative;
width: 24px;
height: 64px;
}
.bd__wax {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 18px;
height: 44px;
border-radius: 4px 4px 0 0;
background: #b9a0ff;
}
.bd__flame {
position: absolute;
left: calc(50% - 5px);
bottom: 42px;
width: 10px;
height: 16px;
border-radius: 50% 50% 50% 50%/62% 62% 38% 38%;
background: radial-gradient(circle at 50% 78%, #fff3c4, #d1bda1 62%, #d1ada1 100%);
transform-origin: 50% 100%;
}
.bd__glow {
position: absolute;
left: calc(50% - 13px);
bottom: 32px;
width: 26px;
height: 26px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 176, 58, 0.5), transparent 70%);
filter: blur(4px);
}
.bd__room {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: radial-gradient(circle at 50% 42%, rgba(255, 176, 58, 0.55), rgba(255, 109, 58, 0.22) 46%, transparent 74%);
}
.bd.is-lit-off .bd__room {
opacity: 0 !important;
animation: none !important;
}
.bd__say {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 3vw, 12px);
color: rgba(23, 20, 26, 0.78);
}
.bd__pct {
margin: 0;
font-weight: 700;
font-size: clamp(13px, 4vw, 16px);
color: #17141a;
}
.bd__pct b {
font-weight: 800;
font-size: 1.6em;
color: #2f6df6;
}
.bd__blowBtn {
border: 0;
cursor: pointer;
background: #ff9ebb;
color: #17141a;
border-radius: 999px;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
padding: 4px 16px;
line-height: 1.3;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bd__blowBtn:active {
transform: scale(0.94);
}
.bd__blowBtn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.bd.is-lit-off .bd__flame {
animation: none;
transform: scaleY(0);
}
.bd.is-lit-off .bd__glow {
animation: none;
opacity: 0;
}
.bd.is-lit-off .bd__say {
visibility: hidden;
}
.bd__live {
margin: 0;
min-height: 1.2em;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(23, 20, 26, 0.6);
}
.bd__flame {
animation: bdFlicker 900ms ease-in-out infinite;
}
@keyframes bdFlicker {
0%, 100% {
transform: skewX(0deg) scaleY(1) scaleX(1);
}
25% {
transform: skewX(4deg) scaleY(1.08) scaleX(0.94);
}
55% {
transform: skewX(-3deg) scaleY(0.94) scaleX(1.05);
}
80% {
transform: skewX(2deg) scaleY(1.05) scaleX(0.97);
}
}
.bd__glow {
animation: bdGlow 900ms ease-in-out infinite;
}
@keyframes bdGlow {
0%, 100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.25);
opacity: 0.55;
}
}
.bd.is-demo .bd__open {
clip-path: inset(0 0 0 0);
}
.bd.is-demo .bd__closed {
visibility: hidden;
}
.bd.is-demo .bd__flame {
animation: bdFlameLoop 2s ease-in-out infinite;
}
@keyframes bdFlameLoop {
0%, 10% {
transform: scaleY(0);
}
16% {
transform: scaleY(1);
}
22% {
transform: skewX(4deg) scaleY(1.08) scaleX(0.94);
}
30% {
transform: skewX(-3deg) scaleY(0.94) scaleX(1.05);
}
38% {
transform: skewX(2deg) scaleY(1.05) scaleX(0.97);
}
46% {
transform: skewX(-4deg) scaleY(1.1) scaleX(0.93);
}
54% {
transform: skewX(3deg) scaleY(0.96) scaleX(1.04);
}
62% {
transform: skewX(0deg) scaleY(1);
}
67%, 92% {
transform: scaleY(0);
}
92.5%, 100% {
transform: scaleY(0);
}
}
.bd.is-demo .bd__glow {
animation: bdGlowLoop 2s ease-in-out infinite;
}
@keyframes bdGlowLoop {
0%, 16% {
transform: scale(0.6);
opacity: 0;
}
22% {
transform: scale(1);
opacity: 0.9;
}
40% {
transform: scale(1.2);
opacity: 0.6;
}
56% {
transform: scale(1.05);
opacity: 0.85;
}
62%, 92% {
transform: scale(0.4);
opacity: 0;
}
92.5%, 100% {
transform: scale(0.4);
opacity: 0;
}
}
.bd.is-demo .bd__room {
animation: bdRoom 2s infinite;
}
@keyframes bdRoom {
0%, 13% {
opacity: 0;
animation-timing-function: steps(1, end);
}
14%, 40% {
opacity: 0.95;
animation-timing-function: ease-in-out;
}
52% {
opacity: 0.7;
}
60% {
opacity: 0.95;
animation-timing-function: steps(1, end);
}
62%, 100% {
opacity: 0;
}
}
.bd.is-open:not(.is-lit-off) .bd__room {
opacity: 0.85;
animation: bdRoomLive 900ms ease-in-out infinite;
}
@keyframes bdRoomLive {
0%, 100% {
opacity: 0.85;
}
50% {
opacity: 0.62;
}
}
.bd.is-demo .bd__pct {
animation: bdPop 2s ease-out infinite;
}
@keyframes bdPop {
0%, 68% {
transform: scale(0.6);
opacity: 0;
}
74%, 92% {
transform: scale(1);
opacity: 1;
}
94%, 100% {
transform: scale(0.6);
opacity: 0;
}
}
.bd.is-demo .bd__say {
animation: bdSayCut 2s steps(1, end) infinite;
}
@keyframes bdSayCut {
0%, 62% {
visibility: visible;
}
62.5%, 92% {
visibility: hidden;
}
92.5%, 100% {
visibility: visible;
}
}
@media (max-width: 340px) {
.bd__candle {
height: 44px;
width: 20px;
}
.bd__wax {
height: 28px;
width: 14px;
}
.bd__flame {
bottom: 26px;
}
.bd__glow {
bottom: 16px;
}
.bd__card {
width: min(100%, 228px);
}
.bd__closed, .bd__open {
gap: 4px;
}
.bd {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.bd.is-demo .bd__flame,
.bd.is-demo .bd__glow,
.bd.is-demo .bd__room,
.bd.is-demo .bd__pct,
.bd.is-demo .bd__say,
.bd.is-open:not(.is-lit-off) .bd__room,
.bd__flame,
.bd__glow {
animation: none !important;
}
.bd.is-demo .bd__room {
opacity: 0.85;
}
.bd__openBtn, .bd__blowBtn {
transition: none !important;
}
.bd.is-demo .bd__flame {
transform: scaleY(1);
}
.bd.is-demo .bd__glow {
transform: scale(1);
opacity: 0.8;
}
.bd.is-demo .bd__pct {
transform: scale(1);
opacity: 1;
}
.bd.is-demo .bd__say {
visibility: hidden;
}
}var bd = document.getElementById('bd');
var openBtn = document.getElementById('openBtn');
var blowBtn = document.getElementById('blowBtn');
var openPane = document.querySelector('.bd__open');
var live = document.getElementById('live');
openBtn.addEventListener('click', function () {
bd.classList.remove('is-demo');
bd.classList.add('is-open');
openPane.setAttribute('aria-hidden', 'false');
live.textContent = 'The birthday coupon is open';
});
blowBtn.addEventListener('click', function () {
bd.classList.remove('is-demo');
bd.classList.add('is-lit-off');
live.textContent = 'Candle out — the 20% discount is locked in';
});// BirthdayCandleCoupon — vanilla 판(run/283/demo/08-coupon-birthday-candle/index.html) 의 React 포트.
// 「리본 풀기」로 카드를 열고(clip-path), 「불 끄기」로 촛불을 꺼서 할인을 확정한다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BirthdayCandleCoupon.module.scss";
export type BirthdayCandleCouponProps = {
/** 격자 미리보기 루프 한 바퀴 (vanilla 2s) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BirthdayCandleCoupon({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ff9ebb",
}: BirthdayCandleCouponProps) {
const [demo, setDemo] = useState(true);
const [opened, setOpened] = useState(false);
const [litOff, setLitOff] = useState(false);
const [liveText, setLiveText] = useState("");
function applyOpen(): void {
setDemo(false);
setOpened(true);
setLiveText("생일 쿠폰이 열렸어요");
}
function applyBlow(): void {
setDemo(false);
setLitOff(true);
setLiveText("불을 껐어요 — 20% 할인이 확정됐어요");
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls: string = [
styles.root,
demo ? styles.isDemo : "",
opened ? styles.isOpen : "",
litOff ? styles.isLitOff : "",
].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.closed}>
<p className={styles.closedTitle}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M104,48c0-24,24-40,24-40s24,16,24,40a24,24,0,0,1-48,0ZM208,96H48a16,16,0,0,0-16,16v23.33c0,17.44,13.67,32.18,31.1,32.66A32,32,0,0,0,96,136a32,32,0,0,0,64,0,32,32,0,0,0,32.9,32c17.43-.48,31.1-15.22,31.1-32.66V112A16,16,0,0,0,208,96Z" opacity="0.2" /><path d="M232,112a24,24,0,0,0-24-24H136V79a32.06,32.06,0,0,0,24-31c0-28-26.44-45.91-27.56-46.66a8,8,0,0,0-8.88,0C122.44,2.09,96,20,96,48a32.06,32.06,0,0,0,24,31v9H48a24,24,0,0,0-24,24v23.33a40.84,40.84,0,0,0,8,24.24V200a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V159.57a40.84,40.84,0,0,0,8-24.24ZM112,48c0-13.57,10-24.46,16-29.79,6,5.33,16,16.22,16,29.79a16,16,0,0,1-32,0ZM40,112a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8v23.33c0,13.25-10.46,24.31-23.32,24.66A24,24,0,0,1,168,136a8,8,0,0,0-16,0,24,24,0,0,1-48,0,8,8,0,0,0-16,0,24,24,0,0,1-24.68,24C50.46,159.64,40,148.58,40,135.33Zm160,96H56a8,8,0,0,1-8-8V172.56A38.77,38.77,0,0,0,62.88,176a39.69,39.69,0,0,0,29-11.31A40.36,40.36,0,0,0,96,160a40,40,0,0,0,64,0,40.36,40.36,0,0,0,4.13,4.67A39.67,39.67,0,0,0,192,176c.38,0,.76,0,1.14,0A38.77,38.77,0,0,0,208,172.56V200A8,8,0,0,1,200,208Z" /></svg>
<span>생일 축하해요!</span>
</p>
<button type="button" className={styles.openBtn} onClick={applyOpen}>리본 풀기</button>
</div>
<div className={styles.open} aria-hidden={opened ? "false" : "true"}>
<span className={styles.room} aria-hidden="true" />
<div className={styles.candle}>
<span className={styles.flame} aria-hidden="true" />
<span className={styles.glow} aria-hidden="true" />
<span className={styles.wax} aria-hidden="true" />
</div>
<p className={styles.say}>소원을 빌고 불을 끄세요</p>
<p className={styles.pct}>생일 <b>20%</b></p>
<button type="button" className={styles.blowBtn} onClick={applyBlow}>불 끄기</button>
</div>
</div>
<p className={styles.live} aria-live="polite">{liveText}</p>
</div>
</main>
);
}08 · Birthday candle coupon — Measured area 22.044% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Birthday and anniversary coupons.
.bd__flame {
position: absolute; left: calc(50% - 5px); bottom: 42px;
width: 10px; height: 16px; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
background: radial-gradient(circle at 50% 78%, #fff3c4, #ffb03a 62%, #ff6d3a 100%);
transform-origin: 50% 100%;
}
// the sway stays alive in real use too — it is the state, not decoration
.bd__flame { animation: bdFlicker 900ms ease-in-out infinite; }
@keyframes bdFlicker {
0%, 100% { transform: skewX(0deg) scaleY(1) scaleX(1); }
25% { transform: skewX(4deg) scaleY(1.08) scaleX(.94); }
55% { transform: skewX(-3deg) scaleY(.94) scaleX(1.05); }
80% { transform: skewX(2deg) scaleY(1.05) scaleX(.97); }
}
// ignition is a one-frame cut so the lit room is the frame the poster picks
@keyframes bdRoom {
0%, 13% { opacity: 0; animation-timing-function: steps(1, end); }
14%, 40% { opacity: .95; animation-timing-function: ease-in-out; }
52% { opacity: .7; }
60% { opacity: .95; animation-timing-function: steps(1, end); }
62%, 100% { opacity: 0; }
}
09Expiring coupon
What moves
A requestAnimationFrame loop turns the milliseconds that are left into a cell index and slides two digit reels by whole em steps, so the numerals change without a single layout pass. At zero the card takes grayscale(1), the code gets a line-through, and an Expired stamp appears below the digits — never over them, because a stamp across the number hides the thing the reader came to check; 22.603% across 15 of 23 frames.

<main class="stage">
<div class="ex is-demo" id="ex">
<div class="ex__card" id="card">
<div class="ex__top">
<p class="ex__title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ex__ico" aria-hidden="true"><path d="M216,136a88,88,0,1,1-88-88A88,88,0,0,1,216,136Z" opacity="0.2"/><path d="M128,40a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,40Zm0,176a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,216ZM173.66,90.34a8,8,0,0,1,0,11.32l-40,40a8,8,0,0,1-11.32-11.32l40-40A8,8,0,0,1,173.66,90.34ZM96,16a8,8,0,0,1,8-8h48a8,8,0,0,1,0,16H104A8,8,0,0,1,96,16Z"/></svg><span>30% coupon, today only</span></p>
<span class="ex__code" id="code">FALL30</span>
</div>
<div class="ex__timer" aria-hidden="true">
<span class="ex__digit"><span class="ex__reel ex__reel--ten"><b>1</b><b>0</b></span></span>
<span class="ex__digit"><span class="ex__reel ex__reel--one"><b>2</b><b>1</b><b>0</b><b>9</b><b>8</b><b>7</b><b>6</b><b>5</b><b>4</b><b>3</b><b>2</b><b>1</b><b>0</b></span></span>
<span class="ex__unit">s</span>
</div>
<p class="ex__warn" id="warn" aria-live="polite"><span class="ex__warnMsg">Time is running out</span></p>
<div class="ex__stampWrap"><span class="ex__stamp">Expired</span></div>
</div>
<button type="button" class="ex__again" id="again">Get it again</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 만료 임박 쿠폰 — 남은 시간은 숫자 릴 두 개(steps)로 세고, 0 이 되면 회색 필터와 취소선으로 지나간 쿠폰이 된다.
// 실사용은 requestAnimationFrame 실시간 카운트다운, 격자 미리보기(.is-demo)는 2초로 압축한 루프.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.ex { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.ex__card {
position: relative; width: min(100%, 268px); background: $subject-cream;
border-radius: $r-card; box-shadow: $shadow-raised; padding: $sp-3 $sp-4 $sp-2;
overflow: hidden;
}
.ex__top { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.ex__title { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: clamp(11px, 3.2vw, 13px); color: $subject-ink; }
.ex__ico { width: 15px; height: 15px; color: $subject-blue; }
.ex__code {
background: rgba(23,20,26,.08); border-radius: $r-pill; color: $subject-ink;
font-weight: 800; font-size: clamp(9px, 2.6vw, 10px); letter-spacing: .12em; padding: 0 $sp-2; line-height: 1.6;
}
.ex.is-expired .ex__code { text-decoration: line-through; color: rgba(23,20,26,.4); }
.ex__timer { display: flex; align-items: baseline; justify-content: center; gap: 0; margin-top: $sp-2; font-weight: 800; color: $subject-ink; }
.ex__digit { display: inline-block; height: 1em; overflow: hidden; line-height: 1; font-size: clamp(34px, 10vw, 44px); }
.ex__reel { display: flex; flex-direction: column; }
.ex__reel b { display: block; height: 1em; line-height: 1; }
.ex__unit { margin-left: $sp-1; font-weight: 800; font-size: clamp(12px, 3.6vw, 16px); color: rgba(23,20,26,.68); }
.ex.is-urgent .ex__timer { color: $error; }
.ex__warn { margin: $sp-1 0 0; text-align: center; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: rgba(23,20,26,.68); min-height: 1.2em; }
.ex.is-urgent .ex__warn { color: $error; }
.ex.is-expired .ex__warn { visibility: hidden; }
.ex.is-expired .ex__card { filter: grayscale(1); }
// 도장은 숫자 위가 아니라 카드 아래쪽 — 겹치면 남은 시간을 못 읽는다(포스터 실측)
.ex__stampWrap { position: absolute; inset: auto 0 $sp-2 0; display: flex; align-items: center; justify-content: center; pointer-events: none; visibility: hidden; }
.ex.is-expired .ex__stampWrap { visibility: visible; }
.ex__stamp {
background: $error; color: $subject-cream; border-radius: $r-pill;
font-weight: 800; font-size: clamp(14px, 4.4vw, 18px); padding: $sp-1 $sp-5;
transform: rotate(-8deg); box-shadow: $shadow-press;
}
.ex__again {
border: 0; cursor: pointer; background: rgba(255,247,230,.16); color: $subject-cream;
border-radius: $r-pill; font-family: inherit; font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px); padding: $sp-1 $sp-3; line-height: 1.3;
transition: transform 140ms cubic-bezier(.2,.8,.2,1), opacity 300ms ease-out;
}
.ex:not(.is-expired) .ex__again { opacity: .4; }
.ex__again:active { transform: scale(.94); }
.ex__again:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 2px; }
// ── 격자 미리보기 루프: 카운트다운 12→00 (5~80%, steps 13칸) → 80% 만료 컷(회색+취소선+도장)
// → 94% 복귀. 일의 자릿수 릴은 구간마다 타이밍을 달리 한다(셀 구간 steps, 복귀 컷).
.ex.is-demo .ex__reel--one { animation: exOnes 2s infinite; }
@keyframes exOnes {
0%, 5% { transform: translateY(0); animation-timing-function: steps(13, end); }
80%, 94% { transform: translateY(-12em); animation-timing-function: steps(1, end); }
94.5%, 100% { transform: translateY(0); }
}
.ex.is-demo .ex__reel--ten { animation: exTens 2s steps(1, end) infinite; }
@keyframes exTens { 0%, 21% { transform: translateY(0); } 21.5%, 94% { transform: translateY(-1em); } 94.5%, 100% { transform: translateY(0); } }
.ex.is-demo .ex__card { animation: exCard 2s steps(1, end) infinite; }
@keyframes exCard { 0%, 79% { filter: grayscale(0); } 79.5%, 93% { filter: grayscale(1); } 93.5%, 100% { filter: grayscale(0); } }
.ex.is-demo .ex__stampWrap { visibility: visible; }
.ex.is-demo .ex__stampWrap { animation: exStamp 2s ease-out infinite; }
@keyframes exStamp { 0%, 79% { transform: scale(0); opacity: 0; } 84%, 92% { transform: scale(1); opacity: 1; } 94%, 100% { transform: scale(0); opacity: 0; } }
.ex.is-demo .ex__code { animation: exCode 2s steps(1, end) infinite; }
@keyframes exCode {
0%, 79% { text-decoration-line: none; color: $subject-ink; }
79.5%, 93% { text-decoration-line: line-through; color: rgba(23,20,26,.4); }
93.5%, 100% { text-decoration-line: none; color: $subject-ink; }
}
@media (max-width: 340px) {
.ex__card { padding: $sp-2 $sp-3 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ex.is-demo .ex__reel--one,
.ex.is-demo .ex__reel--ten,
.ex.is-demo .ex__card,
.ex.is-demo .ex__stampWrap,
.ex.is-demo .ex__code { animation: none !important; }
.ex__again { transition: none !important; }
// 정지 상태: 카운트 초반(12초)의 생생한 쿠폰
.ex.is-demo .ex__reel--ten { transform: translateY(0); }
.ex.is-demo .ex__reel--one { transform: translateY(0); }
.ex.is-demo .ex__card { filter: grayscale(0); }
.ex.is-demo .ex__stampWrap { visibility: hidden; }
}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);
}
.ex {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.ex__card {
position: relative;
width: min(100%, 268px);
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px 8px;
overflow: hidden;
}
.ex__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ex__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
color: #17141a;
}
.ex__ico {
width: 15px;
height: 15px;
color: #2f6df6;
}
.ex__code {
background: rgba(23, 20, 26, 0.08);
border-radius: 999px;
color: #17141a;
font-weight: 800;
font-size: clamp(9px, 2.6vw, 10px);
letter-spacing: 0.12em;
padding: 0 8px;
line-height: 1.6;
}
.ex.is-expired .ex__code {
text-decoration: line-through;
color: rgba(23, 20, 26, 0.4);
}
.ex__timer {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0;
margin-top: 8px;
font-weight: 800;
color: #17141a;
}
.ex__digit {
display: inline-block;
height: 1em;
overflow: hidden;
line-height: 1;
font-size: clamp(34px, 10vw, 44px);
}
.ex__reel {
display: flex;
flex-direction: column;
}
.ex__reel b {
display: block;
height: 1em;
line-height: 1;
}
.ex__unit {
margin-left: 4px;
font-weight: 800;
font-size: clamp(12px, 3.6vw, 16px);
color: rgba(23, 20, 26, 0.68);
}
.ex.is-urgent .ex__timer {
color: #c2361a;
}
.ex__warn {
margin: 4px 0 0;
text-align: center;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(23, 20, 26, 0.68);
min-height: 1.2em;
}
.ex.is-urgent .ex__warn {
color: #c2361a;
}
.ex.is-expired .ex__warn {
visibility: hidden;
}
.ex.is-expired .ex__card {
filter: grayscale(1);
}
.ex__stampWrap {
position: absolute;
inset: auto 0 8px 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
visibility: hidden;
}
.ex.is-expired .ex__stampWrap {
visibility: visible;
}
.ex__stamp {
background: #c2361a;
color: #fff7e6;
border-radius: 999px;
font-weight: 800;
font-size: clamp(14px, 4.4vw, 18px);
padding: 4px 20px;
transform: rotate(-8deg);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ex__again {
border: 0;
cursor: pointer;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
padding: 4px 12px;
line-height: 1.3;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease-out;
}
.ex:not(.is-expired) .ex__again {
opacity: 0.4;
}
.ex__again:active {
transform: scale(0.94);
}
.ex__again:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 2px;
}
.ex.is-demo .ex__reel--one {
animation: exOnes 2s infinite;
}
@keyframes exOnes {
0%, 5% {
transform: translateY(0);
animation-timing-function: steps(13, end);
}
80%, 94% {
transform: translateY(-12em);
animation-timing-function: steps(1, end);
}
94.5%, 100% {
transform: translateY(0);
}
}
.ex.is-demo .ex__reel--ten {
animation: exTens 2s steps(1, end) infinite;
}
@keyframes exTens {
0%, 21% {
transform: translateY(0);
}
21.5%, 94% {
transform: translateY(-1em);
}
94.5%, 100% {
transform: translateY(0);
}
}
.ex.is-demo .ex__card {
animation: exCard 2s steps(1, end) infinite;
}
@keyframes exCard {
0%, 79% {
filter: grayscale(0);
}
79.5%, 93% {
filter: grayscale(1);
}
93.5%, 100% {
filter: grayscale(0);
}
}
.ex.is-demo .ex__stampWrap {
visibility: visible;
}
.ex.is-demo .ex__stampWrap {
animation: exStamp 2s ease-out infinite;
}
@keyframes exStamp {
0%, 79% {
transform: scale(0);
opacity: 0;
}
84%, 92% {
transform: scale(1);
opacity: 1;
}
94%, 100% {
transform: scale(0);
opacity: 0;
}
}
.ex.is-demo .ex__code {
animation: exCode 2s steps(1, end) infinite;
}
@keyframes exCode {
0%, 79% {
text-decoration-line: none;
color: #17141a;
}
79.5%, 93% {
text-decoration-line: line-through;
color: rgba(23, 20, 26, 0.4);
}
93.5%, 100% {
text-decoration-line: none;
color: #17141a;
}
}
@media (max-width: 340px) {
.ex__card {
padding: 8px 12px 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ex.is-demo .ex__reel--one,
.ex.is-demo .ex__reel--ten,
.ex.is-demo .ex__card,
.ex.is-demo .ex__stampWrap,
.ex.is-demo .ex__code {
animation: none !important;
}
.ex__again {
transition: none !important;
}
.ex.is-demo .ex__reel--ten {
transform: translateY(0);
}
.ex.is-demo .ex__reel--one {
transform: translateY(0);
}
.ex.is-demo .ex__card {
filter: grayscale(0);
}
.ex.is-demo .ex__stampWrap {
visibility: hidden;
}
}var ex = document.getElementById('ex');
var again = document.getElementById('again');
var warn = document.getElementById('warn');
var code = document.getElementById('code');
var start = null;
var DURATION = 12000;
function tick(ts) {
if (ex.classList.contains('is-demo')) { requestAnimationFrame(tick); return; } // 격자 루프 중에는 실시간 만료를 멈춘다
if (start === null) { start = ts; }
var left = Math.max(0, DURATION - (ts - start));
var sec = Math.min(12, Math.ceil(left / 1000));
var oneK = 12 - sec; // 릴 셀: 2, 1, 0, 그다음 9 부터 0 까지 — 위에서부터 12-sec 칸째
var tenK = sec >= 10 ? 0 : 1;
document.querySelector('.ex__reel--ten').style.transform = 'translateY(' + (-tenK) + 'em)';
document.querySelector('.ex__reel--one').style.transform = 'translateY(' + (-oneK) + 'em)';
if (left <= 0) {
ex.classList.add('is-expired');
warn.textContent = 'The coupon has expired';
return;
}
if (sec <= 5) { ex.classList.add('is-urgent'); }
requestAnimationFrame(tick);
}
again.addEventListener('click', function () {
if (!ex.classList.contains('is-expired')) { return; }
ex.classList.remove('is-expired', 'is-urgent');
start = null;
warn.textContent = 'Time is running out';
requestAnimationFrame(tick);
});
requestAnimationFrame(tick);// ExpiringCoupon — vanilla 판(run/283/demo/09-coupon-expiry-fade/index.html) 의 React 포트.
// 실사용은 requestAnimationFrame 실시간 카운트다운(12초), 격자 미리보기(demo)는 CSS 루프에 맡기고
// vanilla 처럼 세는 일을 건너뛴다. React 에선 정리 함수에서 cancelAnimationFrame 을 반드시 부른다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ExpiringCoupon.module.scss";
export type ExpiringCouponProps = {
/** 격자 미리보기 루프 한 바퀴 (vanilla 2s) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 실시간 카운트다운 길이 — vanilla 의 DURATION 과 같은 값 */
const COUNTDOWN_MS = 12000;
const WARN_LEFT = "시간이 얼마 안 남았어요";
const WARN_OVER = "쿠폰 기한이 지났어요";
/** 일의 자릿수 릴 셀: 2, 1, 0, 그다음 9 부터 0 까지 */
const ONE_CELLS: string[] = ["2", "1", "0", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"];
const TEN_CELLS: string[] = ["1", "0"];
export default function ExpiringCoupon({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: ExpiringCouponProps) {
const [demo] = useState(true);
const [expired, setExpired] = useState(false);
const [urgent, setUrgent] = useState(false);
const [warnText, setWarnText] = useState(WARN_LEFT);
const [tenK, setTenK] = useState(0);
const [oneK, setOneK] = useState(0);
/** 다시 받기를 누를 때마다 올려 rAF 루프를 처음부터 다시 건다 */
const [runId, setRunId] = useState(0);
useEffect(() => {
let raf = 0;
let start: number | null = null;
let stopped = false;
function tick(ts: number): void {
if (stopped) { return; }
// 격자 루프 중에는 실시간 만료를 멈춘다
if (demo) { raf = window.requestAnimationFrame(tick); return; }
if (start === null) { start = ts; }
const base: number = start ?? ts;
const left = Math.max(0, COUNTDOWN_MS - (ts - base));
const sec = Math.min(12, Math.ceil(left / 1000));
setOneK(12 - sec);
setTenK(sec >= 10 ? 0 : 1);
if (left <= 0) {
setExpired(true);
setWarnText(WARN_OVER);
return;
}
if (sec <= 5) { setUrgent(true); }
raf = window.requestAnimationFrame(tick);
}
raf = window.requestAnimationFrame(tick);
return () => {
stopped = true;
window.cancelAnimationFrame(raf);
};
}, [demo, runId]);
function applyAgain(): void {
if (!expired) { return; }
setExpired(false);
setUrgent(false);
setWarnText(WARN_LEFT);
setRunId((p) => p + 1);
}
/** 미리보기 루프 중에는 인라인 transform 을 얹지 않는다 — CSS 애니메이션이 릴을 돌린다 */
function reelStyle(cell: number): CSSProperties | undefined {
return demo ? undefined : { transform: `translateY(${-cell}em)` };
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls: string = [
styles.root,
demo ? styles.isDemo : "",
urgent ? styles.isUrgent : "",
expired ? styles.isExpired : "",
].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.top}>
<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="M216,136a88,88,0,1,1-88-88A88,88,0,0,1,216,136Z" opacity="0.2" /><path d="M128,40a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,40Zm0,176a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,216ZM173.66,90.34a8,8,0,0,1,0,11.32l-40,40a8,8,0,0,1-11.32-11.32l40-40A8,8,0,0,1,173.66,90.34ZM96,16a8,8,0,0,1,8-8h48a8,8,0,0,1,0,16H104A8,8,0,0,1,96,16Z" /></svg>
<span>오늘까지 30% 쿠폰</span>
</p>
<span className={styles.code}>FALL30</span>
</div>
<div className={styles.timer} aria-hidden="true">
<span className={styles.digit}>
<span className={`${styles.reel} ${styles.reelTen}`} style={reelStyle(tenK)}>
{TEN_CELLS.map((n, i) => <b key={`ten-${i}`}>{n}</b>)}
</span>
</span>
<span className={styles.digit}>
<span className={`${styles.reel} ${styles.reelOne}`} style={reelStyle(oneK)}>
{ONE_CELLS.map((n, i) => <b key={`one-${i}`}>{n}</b>)}
</span>
</span>
<span className={styles.unit}>초</span>
</div>
<p className={styles.warn} aria-live="polite">{warnText}</p>
<div className={styles.stampWrap}><span className={styles.stamp}>기한 지남</span></div>
</div>
<button type="button" className={styles.again} onClick={applyAgain}>다시 받기</button>
</div>
</main>
);
}09 · Expiring coupon — Measured area 22.603% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Promo deadlines and cart expiry alerts.
var DURATION = 12000;
function tick(ts) {
if (ex.classList.contains('is-demo')) { requestAnimationFrame(tick); return; } // the grid loop pauses real expiry
if (start === null) { start = ts; }
var left = Math.max(0, DURATION - (ts - start));
var sec = Math.min(12, Math.ceil(left / 1000));
var oneK = 12 - sec; // reel cell: 2, 1, 0, then 9 down to 0 — the (12 - sec)th cell from the top
var tenK = sec >= 10 ? 0 : 1;
document.querySelector('.ex__reel--ten').style.transform = 'translateY(' + (-tenK) + 'em)';
document.querySelector('.ex__reel--one').style.transform = 'translateY(' + (-oneK) + 'em)';
if (left <= 0) {
ex.classList.add('is-expired');
warn.textContent = 'The coupon has expired';
return;
}
if (sec <= 5) { ex.classList.add('is-urgent'); }
requestAnimationFrame(tick);
}
Where it breaks — the trap
The wheel broke three separate times, and no two of those breaks surfaced the same way. The labels came first: they started life as one inset: 0 plate rotated as a whole, and a rotated square's bounding box swells toward √2 times its side, so a 224px disc measured 310px tall through Playwright's getBoundingClientRect before the fix — taller than the 300px stage — and spilled off it. The same measurement reads 257px now. The cure was to stop rotating a plate at all — each label is now a 44×20px chip placed at rotate(k * 45deg + 22.5deg) translateY(var(--label-r)), and that 22.5deg is half a slice: drop it and every label parks on a slice border instead of in the middle of its color. The radius was the same bug in a different coat, a hard-coded translateY(-84px) throwing labels 18px clear of a 132px disc on narrow screens, which is why the radius moved into --label-r, dropping to -48px at 340px and under. The third break no measurement would ever have caught: the Draw button sat inside the disc, so the word turned upside down with the spin. A click found that one.
Two other items lost their poster to one shared mistake — putting a state change inside the preview loop. In the fanned stack the collapse-and-open round trip made the collapsing frames the largest per-frame change, so three renders in a row chose a stack of overlapping cards as the representative image, which is precisely what the item is not about; the loop now holds the spread state and only lifts one card, leaving collapse-to-fan to the real :hover and :focus-within transition. The birthday card had it worse: with the opening wipe inside the loop the poster was always the closed pink card and the candle never appeared at all, so the loop starts open, and since fading the flame in gradually made the biggest-change frame a still-dark room, ignition became a steps(1, end) cut.
Hover conceals one more failure, and it has nothing to do with posters. On a touch screen there is no hover, so the back cards of a stack can never be reached — a first tap now fans the stack through an is-fanned class before a second tap picks a card.
Two sweeps closed the rest. A Playwright pass over every on-screen string turned up 22 contrast failures, all of them measured before the fix and none reproducible from the shipped source: the wheel's status line sat on the orange stage at 3.11:1 in cream and still only 3.32:1 once it went white, so it moved onto an ink pill; the fanned cards' orange tags carried cream text at 3.11:1 and became yellow tags with ink text, now 12.63:1; and pale ink on cream from 2.78 to 4.04:1 across six items had its opacity raised from .5, .55 and .6 up to .68, .72 and .78. The before-fix readings are kept in _수리전실측.json beside the sources. At 320×200, before the fix, six items overflowed by 5 to 32px, and every one of those fixes lowered a value inside the narrow-screen media query rather than raising one — the stamp slot border from 2px to 1px, the disc from 204px to 132px, the card width from 268px to 228px. Support was checked on caniuse on 2026-09-12: conic-gradient() sits at 96.44% globally, from Chrome 69, Safari 12.1, Firefox 83, and Edge 79, with Opera Mini the one holdout, and CSS masks at 97.03% but only fully from Chrome 120, Safari 15.4, and Firefox 53 — which is exactly why the notched ticket ships -webkit-mask-composite: destination-in alongside the standard mask-composite: intersect. Dropping these coupons into one cell of an admin screen uses the same sizing as the standalone dashboard widgets. All nine sources, both the grid loop and the real interaction path, are in the file below, and it opens with rnpgy6qn.
Three variations
| Variation | Changed value | How it feels |
|---|---|---|
| Six-slice wheel | conic-gradient stops every 60deg, labels at --k * 60deg + 30deg |
Fatter prizes, still legible on a 320px screen |
| Wider scratch coin | ctx.arc(x, y, 16, 0, Math.PI * 2) radius 16 to 28 |
A thumb clears the foil in two strokes instead of six |
| Four-column stamp card | repeat(5, 1fr) becomes repeat(4, 1fr) |
The same ten slots reflow into four columns over three rows, so the card grows taller and narrower |
AI prompt
The zip carries the full prompt; this is the shape of it. Name the nine coupons by their physical parts, not by CSS, and close with a negative list — image models will otherwise bevel everything and put a smiling hand on the card.
Design a coupon component board for an online shop: 9 cards, one board.
Flat vector, cream #fff7e6 cards on ink #17141a, blue #1c3f9e accents.
Show, in order: a ticket with half-circle notches at the top and bottom of its stub line,
a ten-slot stamp card with seven slots filled, an eight-slice prize wheel
with a pointer at 12 o'clock, a silver scratch panel half rubbed away,
a gift card caught mid-flip, three coupons fanned out of a stack,
a barcode card crossed by a green scan beam, an opened birthday card
with one lit candle, and a gray expired coupon under a red stamp.
Every card 4:3, shadows no softer than 2px, flat fills only.
Negative: gradients on text, 3D bevels, glitter, stock-photo hands.
Accessibility (reduced-motion)
Each item answers prefers-reduced-motion: reduce by keeping the finished state rather than the journey to it, so nothing disappears when the motion does. The ticket and the gift card drop every animation and transition, and the gift card simply rests on its back face. The stamp card shows all ten stamps at once, the wheel skips the spin and displays the winning message straight away, and the scratch panel parks at clip-path: inset(0 0 0 62%) with the coin gone — 62% of the foil cleared, nothing moving. The fan holds its spread transform with the animations off, the barcode leaves the beam at translateY(28px) with the paid state already applied, the birthday card stays lit with the room at .85, and the expiring coupon freezes on the healthy twelve-second state instead of the gray one. Focus rings are untouched throughout: every button carries a 3px :focus-visible outline, which is the one piece of feedback a keyboard user cannot do without.
FAQ
Why do these coupon design ideas cut the notches with a mask instead of two overlaid circles?
Overlaid circles have to match whatever is behind the card, so the trick collapses the moment the coupon sits on a photo or a gradient. A mask removes the pixels, which means the background actually shows through, and mask-composite: intersect lets both notches come off one element instead of two stacked wrappers. To weigh a discount against a plan with a toggle, pricing table interactions run that arithmetic live.
Can the spin wheel land on a prize I choose?
Yes, and it should. The loop in the grid is decorative and always turns 1800deg, but the click path applies a transition to a target angle, so the server decides the winning slice and the CSS only animates toward the angle that slice occupies.
Does the countdown keep running when the tab is in the background?
requestAnimationFrame is throttled or paused in a hidden tab, so the reel stops updating. Because the remaining time is derived from a timestamp difference on every frame and not from a counter, the digits are correct again on the first frame after the tab comes back.









