06쿠폰 더미 부채꼴
보관함에 겹쳐 둔 쿠폰 더미입니다. 호버하면 카드가 --i 순서대로 부채꼴로 펼쳐져 세 장의 조건을 한눈에 비교하고, 원하는 장을 클릭해 결제에 올릴 수 있습니다.
쓰임마이페이지 쿠폰 보관함, 결제 전 쿠폰 선택
--irotatetranslateX

<main class="stage">
<div class="fan is-demo" id="fan">
<div class="fan__stack" id="stack" role="group" aria-label="보관함 쿠폰 세 장">
<button type="button" class="fan__card fan__card--1" style="--i: 0" data-msg="첫 구매 10%을 결제에 올렸어요" aria-pressed="false">
<span class="fan__tag">신규</span>
<span class="fan__pct">10<i>%</i></span>
<span class="fan__name">첫 구매 할인</span>
</button>
<button type="button" class="fan__card fan__card--2" style="--i: 1" data-msg="브랜드데이 20%을 결제에 올렸어요" aria-pressed="false">
<span class="fan__tag">이벤트</span>
<span class="fan__pct">20<i>%</i></span>
<span class="fan__name">브랜드데이 할인</span>
</button>
<button type="button" class="fan__card fan__card--3" style="--i: 2" data-msg="무료배송 쿠폰을 결제에 올렸어요" aria-pressed="false">
<span class="fan__tag">배송</span>
<span class="fan__pct">0원</span>
<span class="fan__name">무료배송 쿠폰</span>
</button>
</div>
<p class="fan__live" id="live" aria-live="polite">호버하면 펼쳐져요</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 ? '선택을 해제했어요' : 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 쿠폰 더미 부채꼴 — 측정 누적면적 29.548% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 쿠폰 디자인 9종 — 도장부터 돌림판까지 복붙 코드
이 데모가 속한 글
쿠폰 디자인 9종 — 도장부터 돌림판까지 복붙 코드쿠폰 디자인은 할인을 받고 싶어지게 쿠폰 카드의 생김새와 움직임을 짜는 일입니다. 여기 아홉 가지는 노치 티켓·도장 적립판·돌림판·스크래치·바코드처럼 가게에서 실제로 쓰는 쿠폰 부품을 CSS 와 약간의 JS 로 만들 이 글의 데모 전부 보기


