09Expiring coupon
A coupon running out of time. The countdown ticks down in real time on requestAnimationFrame, and at zero a gray cast and a strike-through turn it into a spent coupon.
Best forPromo deadline notice, cart coupon expiry alert
requestAnimationFramegrayscaleline-through

<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 · Coupon Design: 9 CSS Coupons, Stamp to Spin
The post this demo lives in
Coupon Design: 9 CSS Coupons, Stamp to SpinCoupon design is what a discount looks like on screen and how it behaves — notch, tear line, stamp slot, countdown. Thes See all demos in this post


