쿠폰 디자인 9종 — 도장부터 돌림판까지 복붙 코드
쿠폰 디자인은 할인을 받고 싶어지게 쿠폰 카드의 생김새와 움직임을 짜는 일입니다. 여기 아홉 가지는 노치 티켓·도장 적립판·돌림판·스크래치·바코드처럼 가게에서 실제로 쓰는 쿠폰 부품을 CSS 와 약간의 JS 로 만들어 둔 것이라, 붙여 넣으면 그대로 돕니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 노치 티켓 쿠폰
- 02 스탬프 적립 카드
- 03 돌림판 쿠폰 룰렛
- 04 스크래치 쿠폰
- 05 기프트 카드 뒤집기
- 06 쿠폰 더미 부채꼴
- 07 바코드 스캔 쿠폰
- 08 생일 쿠폰 촛불
- 09 만료 임박 쿠폰
아홉 개를 늘어놓은 기준은 유행이 아니라 손님 한 사람이 쿠폰을 만나는 순서입니다. 먼저 받는 쪽 둘 — 반원 노치가 파인 티켓을 눌러 담고(01), 주문할 때마다 도장을 찍어 모읍니다(02). 다음은 뽑는 쪽 둘 — 판을 돌려 당첨 칸을 고르고(03), 은박을 긁어 숫자를 드러냅니다(04). 그다음은 주고받는 쪽 둘 — 기프트 카드를 뒤집어 잔액과 PIN 을 확인하고(05), 보관함에 겹친 더미를 부채꼴로 펼쳐 조건을 견줍니다(06). 마지막 셋은 쓰고 끝나는 쪽 — 계산대에서 바코드를 보여 결제하고(07), 생일에는 촛불을 끄고(08), 시간이 다 되면 회색으로 식습니다(09). 무대 색은 잉크 셋·옐로 둘·오렌지 둘·종이 둘로 돌려 격자 아홉 칸의 밝기가 한쪽으로 쏠리지 않게 맞췄습니다. 이 쿠폰들이 결제 흐름 어디에 끼는지는 장바구니·결제 UI 9종이 다루고, 같은 할인을 메일로 보내는 쪽은 뉴스레터 디자인 9종에 있습니다. 카드 위에 붙는 「한정」·「품절」 같은 작은 딱지는 상태 배지 모음이 따로 모아 두었습니다.
01노치 티켓 쿠폰
점선 재단선이 닿는 위아래 두 곳에 반원 노치가 파여 종이 티켓 모양을 만들고, 담기를 누르면 버튼이 살짝 눌렸다가 보관함 숫자가 0 에서 1 로 바뀝니다. 카드가 4px 떠오르는 정도만 계속 움직여 누적면적 5.972% 로 아홉 개 중 가장 조용하지만, 둥둥이 한 번도 끊기지 않아 움직인 프레임은 23/23 입니다. 첫 구매 쿠폰 다운로드 카드와 프로모션 랜딩의 쿠폰 카드에 씁니다.
.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;
}
02스탬프 적립 카드
도장 열 칸이 0.12초 간격으로 차례차례 내리찍히고, 마지막 칸이 차면 발급 배지가 튀어 오릅니다. 도장 하나가 1.6배에서 제 크기로 줄며 기우는 동작이 열 번 이어져 누적면적 19.376%, 움직인 프레임 19/23 을 기록했습니다. 카페·멤버십 적립 카드와 주문 완료 화면의 도장판에 씁니다.
.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); }
}
03돌림판 쿠폰 룰렛
여덟 칸짜리 판을 conic-gradient 한 줄로 칠하고, 뽑기를 누르면 감속 이징으로 다섯 바퀴(1800deg)에 당첨 칸까지의 각을 더해 돌아 그 칸이 포인터 아래에 멈춥니다. 화면 가운데가 통째로 도는 만큼 누적면적 32.837%·강도 평균 138.0 으로, 카드 색이 통째로 죽는 09(139.9) 다음으로 세게 움직이고 움직인 프레임은 21/23 입니다. 가입 환영 이벤트 팝업과 앱 첫 실행 룰렛에 씁니다.
.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);
}
04스크래치 쿠폰
은박은 canvas 에 칠하고 포인터가 지나간 자리를 globalCompositeOperation 의 destination-out 으로 진짜로 지웁니다. 알파 값을 세어 잰 벗겨진 비율이 55% 를 넘으면 남은 은박이 저절로 걷히고, 격자 루프는 이 과정을 누적면적 22.853%·움직인 프레임 19/23 으로 재현합니다. 랜덤 보상 쿠폰과 이벤트 응모 결과 카드에 씁니다.
// 실제 긁기 — pointermove 로 destination-out 원을 계속 지운다
function scratchAt(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 16, 0, Math.PI * 2);
ctx.fill();
}
// 얼마나 벗겨졌나 — 알파 샘플로 실측
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;
}
05기프트 카드 뒤집기
카드를 누르면 rotateY(180deg) 로 돌아가며 앞면의 금액이 뒷면의 잔액과 PIN 가림판으로 바뀌는데, 뒤판을 미리 180도 돌려 두고 backface-visibility: hidden 으로 등을 감춰 두 판이 한 장처럼 맞물립니다. 카드 면 전체가 돌아 누적면적 34.467% 로 아홉 개 중 가장 넓게 바뀌지만 앞뒤가 모두 밝은 색이라 강도 평균은 60.3 에 그쳐 눈에는 부드럽고, 움직인 프레임은 23/23 입니다. 선물하기 페이지와 금액권 선택 카드에 씁니다.
.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--front { background: $subject-lilac; color: $subject-ink; }
.gc__face--back { background: $subject-cream; color: $subject-ink; transform: rotateY(180deg); gap: $sp-2; }
06쿠폰 더미 부채꼴
겹쳐 둔 카드 세 장이 --i 순서대로 좌우로 벌어져 조건을 한눈에 견주게 하고, 가운데 한 장이 들리며 결제에 올릴 카드를 고릅니다. 양옆 두 장이 서로 다른 폭으로 벌어졌다 모이며 누적면적 29.548%, 움직인 프레임 23/23 을 기록했습니다. 마이페이지 쿠폰 보관함과 결제 직전 쿠폰 선택에 씁니다.
// 실제 조작: 스택에 호버·초점이 닿거나(마우스·키보드) 한 번 누르면(터치) 부채꼴로 펼침
.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; }
07바코드 스캔 쿠폰
굵기가 제각각인 막대는 주기 4px·9px 짜리 repeating-linear-gradient 두 겹을 3px 어긋나게 겹쳐 그렸고, 스캔을 누르면 초록 빔이 60px 내려간 뒤 결제 완료로 컷 전환합니다. 움직이는 것이 4px 높이 빔 한 줄뿐이라 강도 평균 53.5 로 아홉 개 중 변화의 세기가 가장 약하고, 누적면적 12.737%·움직인 프레임 15/23 입니다. 매장 결제 화면과 멤버십 바코드 카드에 씁니다.
.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); } }
08생일 쿠폰 촛불
분홍 카드가 열리면 촛불이 켜지고, 불꽃은 심지 쪽 고정축(transform-origin: 50% 100%)을 중심으로 좌우로 기울며 늘었다 줄었다 흔들립니다. 불 끄기를 누르면 불꽃이 세로로 납작해지고 카드를 물들이던 주황 조명도 같이 꺼져 누적면적 22.044%·움직인 프레임 17/23 을 기록했습니다. 생일 축하 메시지 카드와 기념일 쿠폰에 씁니다.
.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%;
}
// 불꽃 흔들림 — 실사용에서도 계속 산다(장식이 아니라 상태 표시)
.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); }
}
09만료 임박 쿠폰
남은 시간은 requestAnimationFrame 이 매 프레임 다시 계산해 숫자 릴 두 개를 한 칸씩 밀어 올리고, 0 이 되면 카드에 회색 필터가 씌워지고 쿠폰 코드에 취소선이 그어져 지나간 쿠폰이 됩니다. 초반에는 숫자만 바뀌다 끝에서 카드 전체 색이 죽는 구성이라 강도 평균이 139.9 로 아홉 개 중 가장 높고, 누적면적 22.603%·움직인 프레임 15/23 입니다. 프로모션 마감 안내와 장바구니 쿠폰 만료 알림에 씁니다.
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 = '쿠폰 기한이 지났어요';
return;
}
if (sec <= 5) { ex.classList.add('is-urgent'); }
requestAnimationFrame(tick);
}
어디서 깨지는가 — 함정
이 아홉 개에서 가장 많이 시간을 잡아먹은 것은 회전한 요소의 경계 상자였습니다. 03 돌림판의 라벨을 처음에는 inset: 0 짜리 큰 판에 얹어 원판과 함께 통째로 돌렸는데, 정사각형 판이 45도 기울면 경계 상자가 한 변의 √2 배 가까이 부푸는 탓에 수리 전 Playwright 로 잰 판 높이가 지름 224px 에서 310px 로 늘어 300px 짜리 무대를 넘었습니다. 라벨을 44×20px 조각 여덟 개로 쪼개 rotate(k*45deg + 22.5deg) translateY(var(--label-r)) 로 칸 중앙에 놓고 같은 측정에서 257px 로 들어왔는데, 여기서 22.5deg 를 빼먹으면 라벨이 칸 경계선에 정확히 걸립니다. 같은 03 에서 라벨 반지름을 translateY(-84px) 로 못 박아 두었더니 판 지름이 132px 로 줄어드는 좁은 화면에서는 반지름이 66px 뿐이라 라벨이 판 밖으로 18px 삐져나왔고, 이 값을 var(--label-r) 로 빼서 좁은 화면에만 -48px 을 주어 막았습니다. 「뽑기」 버튼도 처음에는 판 안에 있어서 눌러 보니 글자까지 같이 거꾸로 돌았습니다 — 허브는 판의 자식이 아니라 형제여야 합니다.
두 번째는 대표 이미지가 항상 최대 변화 프레임에서 뽑힌다는 점입니다. 06 부채꼴에서 접었다 펴는 왕복을 미리보기 루프에 넣었더니 접히는 구간이 프레임당 변화가 가장 커서 세 번 다시 렌더할 때마다 대표 이미지가 「겹쳐 있는 더미」로 나왔습니다. 주제인 부채꼴이 안 보이니 루프는 펼친 상태를 유지하고 한 장을 골라 드는 연출만 남겼고, 접힘에서 펼침으로 가는 전환은 실사용의 :hover·:focus-within 쪽에 두었습니다. 08 촛불도 같은 문제를 겪었습니다. 개봉 wipe 를 루프에 넣으면 대표 이미지가 늘 닫힌 분홍 카드라 촛불이 아예 안 보여 루프를 열린 상태에서 시작하게 했고, 점화까지 서서히 밝히자 이번에는 최대 변화 프레임이 「아직 어두운 중간」이 되어 촛불빛이 안 찍혔습니다. 점화만 steps(1, end) 컷으로 바꾸니 방이 환해지는 그 장이 잡혔습니다.
세 번째는 작은 화면과 대비입니다. 수리 전 320×200 에서 02·03·05·06·07·08 여섯 개가 5~32px 씩 넘쳐 좁은 화면 미디어쿼리를 붙였는데, 이때 값을 키우지 않고 줄이는 쪽으로만 풀었습니다 — 도장 칸 테두리 2px 을 1px 로, 판 지름 204px 을 132px 로, 카드 폭 268px 을 228px 로 줄이는 식입니다. 대비도 Playwright 로 화면 글자를 전수 측정해 수리 전 미달 22건을 고쳤습니다. 03 안내 문구는 주황 무대 위라 크림일 때 3.11:1, 흰색으로 올려도 3.32:1 이라 잉크 알약 배경 위로 옮겼고, 06 태그는 주황 배경(#ff4d1f) 위 크림 글자 3.11:1 을 노랑 태그에 잉크 글자로 뒤집어 12.63:1 이 됐습니다. 크림·흰 카드 위 옅은 잉크 글자 2.78~4.04:1(01·02·04·05·08·09)은 불투명도를 .5~.6 에서 .68~.78 로 올렸습니다. 06 에는 터치 함정도 하나 있습니다. 호버가 없는 기기에서는 카드가 영영 겹친 채라 뒤 두 장을 누를 수 없어, 첫 탭이 더미를 펼치는 is-fanned 경로를 따로 넣었습니다. 이렇게 손으로 고친 자리까지 전부 담긴 원본 아홉 벌은 아래 zip 안에 있고, 여는 비밀번호는 <span class="pw-inline" id="pw">rnpgy6qn</span> 입니다.
변형 3개
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 넓은 노치 | 01 circle 10px → circle 16px |
파인 자리가 깊어져 종이 티켓 느낌이 세집니다 |
| 12칸 룰렛 | 03 conic-gradient 색 구간과 라벨 각도를 45deg → 30deg |
당첨 확률이 낮아 보여 이벤트 긴장감이 올라갑니다 |
| 얕은 부채꼴 | 06 rotate(-8deg) → rotate(-4deg), translateX(min(-34px, -9vw)) → -20px |
카드가 덜 벌어져 좁은 보관함에서도 안 잘립니다 |
AI 프롬프트
바이브코딩으로 쿠폰 하나를 새로 만들 때는 모양·움직임·멈춤 조건 셋을 한 번에 못 박는 편이 고쳐 쓰는 횟수를 줄입니다. 아래는 04 스크래치를 만들 때 쓴 조각입니다.
캔버스 한 장으로 스크래치 쿠폰을 만든다.
모양: 크림색 카드 안 240x88 영역, 은박은 좌우 밝기가 다른 선형 그라데이션 + 잔금 26개.
움직임: pointerdown 부터 pointermove 를 따라 반지름 16px 원을 destination-out 으로 지운다.
멈춤 조건: 알파 40 미만 픽셀 비율이 0.55 를 넘으면 남은 은박을 한 번에 지우고 aria-live 로 할인율을 알린다.
금지: 마우스 전용 이벤트, 고정 px 폰트, 300ms 넘는 지연.
destination-out 자리를 destination-in 으로 바꾸면 긁은 자리만 남고 나머지가 지워지니, 원하는 결과와 반대로 나올 때는 이 한 낱말부터 봅니다.
접근성
prefers-reduced-motion: reduce 에서 아홉 개 모두 애니메이션을 끄되 결과가 보이는 정지 화면을 남깁니다. 01 은 티켓과 숫자를 담기 전 상태로 세우고, 02 는 감춰 두었던 도장 열 칸을 visibility: visible 로 전부 보여 줍니다. 03 은 회전을 없애고 당첨 문구만 바로 띄우며, 04 는 동전을 없앤 뒤 은박을 inset(0 0 0 62%) 로 62% 지점까지 걷어 둡니다. 05 는 뒷면을 펼친 채로, 07 은 빔을 가운데 세우고 결제 완료 상태로, 08 은 불이 켜져 방이 환한 상태로, 09 는 아직 12초가 남은 생생한 쿠폰으로 멈춥니다. 06 은 부채꼴로 펼친 자세를 그대로 두어 세 장을 동시에 읽을 수 있습니다. 상태를 말로도 전하려고 02 는 진행률을 aria-valuenow 에, 04·07·09 는 결과를 aria-live 영역에 적고, 버튼마다 :focus-visible 윤곽선을 무대 색과 다른 색으로 줬습니다.
FAQ
conic-gradient 와 mask 는 지금 브라우저에서 다 되나요
caniuse 기준 conic-gradient() 는 전역 96.44%(Chrome 69·Safari 12.1·Firefox 83·Edge 79 이상, Opera Mini 는 미지원), CSS masks 는 전역 97.03% 입니다. 다만 mask 는 Chrome 120·Safari 15.4·Firefox 53 이전이 부분 지원이라, 01 노치는 -webkit-mask·-webkit-mask-composite: destination-in 과 표준 mask-composite: intersect 를 나란히 적어 양쪽 엔진을 모두 맞췄습니다.
돌림판 당첨을 서버에서 정하려면 어디를 고치나요
.wh__disc 의 최종 각도를 결정하는 자리 한 곳만 서버 응답으로 바꾸면 됩니다. 당첨 칸 번호 n 을 받아 360 * 바퀴수 + (360 - n * 45 - 22.5) 를 회전각으로 넣으면 그 칸 중앙이 포인터 아래에 서고, 감속은 transition: transform 1.8s cubic-bezier(.12, .8, .08, 1) 가 그대로 맡습니다.
스크래치를 모바일에서 확대되지 않게 하려면
긁는 영역에 touch-action: none 을 주어 브라우저가 그 제스처를 스크롤이나 확대로 가져가지 않게 합니다. 여기서는 pointerdown 때 setPointerCapture 로 포인터를 캔버스에 묶어 손가락이 영역 밖으로 나가도 선이 끊기지 않게 했고, 긁기를 못 하는 환경을 위해 「바로 보기」 버튼을 따로 두었습니다.