뉴스레터 디자인 9종 — 메일 앱에서 안 깨지는 HTML
뉴스레터 디자인은 정기적으로 오는 마케팅 메일의 겉모습을 짜는 일입니다. 여기 아홉 가지는 다이제스트·쿠폰·타임세일·장바구니 리마인드 같은 자주 보내는 마케팅 메일을 HTML 과 CSS 로 짜 둔 것이라, 메일 앱에서 안 깨지는 표(table) 세로 줄을 뼈대로 삼아 붙여 넣으면
01 newsletter
02 promo
03 flash-sale
04 abandoned-cart
05 event-invite
06 onboarding
07 survey
08 reengagement
09 referral
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 뉴스레터 다이제스트
- 02 할인 쿠폰 메일
- 03 타임세일 남은 시간 띠
- 04 장바구니 이탈 리마인드
- 05 행사 초대 · 참석 여부
- 06 온보딩 체크리스트
- 07 별점 설문 메일
- 08 휴면 고객 재방문 메일
- 09 친구 추천 코드 메일
아홉 개의 뉴스레터 디자인 예시를 놓은 순서는 유행이 아니라 한 사람이 메일을 받는 순서입니다. 먼저 정기적으로 오는 것 — 읽을 거리를 골라 담은 다이제스트(01)로 시작합니다. 다음은 사고 싶게 만드는 셋 — 쿠폰 코드를 눌러 복사하는 메일(02), 남은 시간이 줄어드는 타임세일 띠(03), 두고 간 장바구니를 다시 보여주는 리마인드(04)입니다. 그다음은 관계를 묻는 셋 — 행사 참석 여부를 세 버튼으로 묻고(05), 가입 뒤 남은 단계를 체크리스트로 세우며(06), 별 하나로 만족도를 묻습니다(07). 마지막 둘은 돌아오게 하고 퍼지게 하는 것 — 떠나 있던 동안의 숫자를 굴려 보여주고(08), 친구 초대 코드로 퍼뜨립니다(09). 무대 색은 잉크 셋·옐로 둘·오렌지 둘·흰 바탕 둘로 돌려 밝기를 고르게 맞췄고, 카드 폭은 전부 최대 400px 로 아이폰 세로 화면에서 양쪽이 잘리지 않습니다. 거래 확인·영수증 같은 사고 나서 오는 메일은 이메일 템플릿 디자인 9종이 이미 다루었고, 이번 편은 팔기 위해 보내는 메일 차례입니다. 메일 맨 아래 수신거부와 법적 고지를 모으는 자리는 웹 페이지와 같은 문제인데, 푸터 디자인 패턴이 그 축을 정리해 둡니다.
01뉴스레터 다이제스트
뉴스레터 템플릿에서 무엇이 보이나
제호 아래 기사 세 줄이 0.12초 간격으로 아래에서 차례로 올라오고, 줄마다 읽는 시간 배지가 붙어 무엇부터 읽을지 고를 수 있게 합니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">
<span class="mk__brandmark" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></span>
◆ Acme 주간편지 <span class="mk__issue">59호</span>
</td></tr>
<tr><td class="mk__title">이번 주에 잘 읽힌 3편</td></tr>
<tr><td class="mk__sub">연 사람이 많은 순서로 담았어요.</td></tr>
<tr><td class="mk__rows">
<button type="button" class="mk__row" aria-pressed="false">
<span class="mk__num" aria-hidden="true">1</span>
<span class="mk__head">굵은 글씨 하나로 화면 순서를 만드는 법</span>
<span class="mk__time">6분</span>
</button>
<button type="button" class="mk__row" aria-pressed="false">
<span class="mk__num" aria-hidden="true">2</span>
<span class="mk__head">버튼이 눌렸다고 느껴지는 0.1초의 정체</span>
<span class="mk__time">4분</span>
</button>
<button type="button" class="mk__row" aria-pressed="false">
<span class="mk__num" aria-hidden="true">3</span>
<span class="mk__head">카드가 겹치는 첫 화면, 각을 잡는 순서</span>
<span class="mk__time">8분</span>
</button>
</td></tr>
<tr><td align="center" class="mk__ctawrap"><a href="#" role="button" class="mk__cta">전체 편지 보기</a></td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-ink);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand {
font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62);
letter-spacing: .02em; padding-top: $sp-2 !important;
display: flex; align-items: center; gap: $sp-1;
}
.mk__brandmark {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(22px, 6vw, 28px); height: clamp(22px, 6vw, 28px); border-radius: $r-sm;
background: #0a4fe8; color: $subject-cream; flex: 0 0 auto;
}
.mk__ico { width: 62%; height: 62%; }
.mk__issue { font-weight: 600; color: rgba(23,20,26,.62); }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 15px); line-height: 1.25; color: $subject-ink; padding-top: $sp-1 !important; }
.mk__sub { font-weight: 500; font-size: clamp(9px, 2.8vw, 10px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__rows { padding-top: 0 !important; padding-bottom: 0 !important; }
.mk__row {
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: $sp-2;
width: 100%; text-align: left; background: none; border: none; cursor: pointer;
font-family: inherit; padding: $sp-1 0; border-top: 1px solid rgba(0,0,0,.06);
}
.mk__num {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 50%; background: rgba(23,20,26,.08);
font-weight: 800; font-size: 9px; line-height: 1; color: rgba(23,20,26,.62);
}
.mk__head { font-weight: 700; font-size: clamp(10px, 3vw, 12px); line-height: 1.3; color: $subject-ink; }
.mk__time {
font-weight: 700; font-size: 8px; line-height: 1; color: #6079b0;
background: rgba(47,109,246,.12); border-radius: $r-pill; padding: $sp-1 $sp-2; white-space: nowrap;
}
.mk__row.is-read .mk__head { color: rgba(23,20,26,.62); }
.mk__row.is-read .mk__time { color: rgba(23,20,26,.62); background: rgba(23,20,26,.06); }
.mk__ctawrap { padding-top: $sp-2 !important; padding-bottom: $sp-1 !important; }
.mk__cta {
display: inline-block; padding: $sp-1 $sp-6; font-weight: 700; font-size: 11px; line-height: 1.6;
font-family: inherit; color: $subject-cream; text-decoration: none; border-radius: $r-control;
background: #7c90be; // CTA 파랑을 어둡게 — 크림 글자 4.25:1 미달이라 5.29:1 로 (279 선례)
}
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 세 줄이 아래에서 차례로 올라온다 (2s 주기, 복귀는 88% 안)
.mk.is-demo .mk__row { animation-name: nlRise; animation-duration: 2s; animation-timing-function: $easing; animation-iteration-count: infinite; }
.mk.is-demo .mk__row:nth-child(2) { animation-delay: 120ms; animation-fill-mode: backwards; }
.mk.is-demo .mk__row:nth-child(3) { animation-delay: 240ms; animation-fill-mode: backwards; }
.mk.is-demo .mk__cta { animation-name: nlCta; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
@keyframes nlRise {
0% { opacity: 0; transform: translateY(14px); }
30% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
88% { opacity: 0; transform: translateY(8px); }
100% { opacity: 0; transform: translateY(14px); }
}
@keyframes nlCta {
0%, 10% { opacity: .35; transform: translateY(8px); }
40% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
92% { opacity: .35; transform: translateY(8px); }
100% { opacity: .35; transform: translateY(8px); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__brandmark { width: 18px; height: 18px; }
.mk__title { font-size: 11px !important; padding-top: $sp-1 !important; }
.mk__sub { font-size: 8px !important; padding-bottom: 0 !important; }
.mk__head { font-size: 9px !important; }
.mk__num { width: 14px; height: 14px; font-size: 8px; }
.mk__time { font-size: 7px !important; padding: 0 $sp-1 !important; }
.mk__cta { padding: 0 $sp-5 !important; font-size: 10px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__row,
.mk.is-demo .mk__cta { animation: none !important; opacity: 1; transform: none; }
}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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
display: flex;
align-items: center;
gap: 4px;
}
.mk__brandmark {
display: inline-flex;
align-items: center;
justify-content: center;
width: clamp(22px, 6vw, 28px);
height: clamp(22px, 6vw, 28px);
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
flex: 0 0 auto;
}
.mk__ico {
width: 62%;
height: 62%;
}
.mk__issue {
font-weight: 600;
color: rgba(23, 20, 26, 0.62);
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 15px);
line-height: 1.25;
color: #17141a;
padding-top: 4px !important;
}
.mk__sub {
font-weight: 500;
font-size: clamp(9px, 2.8vw, 10px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__rows {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mk__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
padding: 4px 0;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.mk__num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.08);
font-weight: 800;
font-size: 9px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
}
.mk__head {
font-weight: 700;
font-size: clamp(10px, 3vw, 12px);
line-height: 1.3;
color: #17141a;
}
.mk__time {
font-weight: 700;
font-size: 8px;
line-height: 1;
color: #6079b0;
background: rgba(47, 109, 246, 0.12);
border-radius: 999px;
padding: 4px 8px;
white-space: nowrap;
}
.mk__row.is-read .mk__head {
color: rgba(23, 20, 26, 0.62);
}
.mk__row.is-read .mk__time {
color: rgba(23, 20, 26, 0.62);
background: rgba(23, 20, 26, 0.06);
}
.mk__ctawrap {
padding-top: 8px !important;
padding-bottom: 4px !important;
}
.mk__cta {
display: inline-block;
padding: 4px 24px;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #fff7e6;
text-decoration: none;
border-radius: 12px;
background: #7c90be;
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .mk__row {
animation-name: nlRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__row:nth-child(2) {
animation-delay: 120ms;
animation-fill-mode: backwards;
}
.mk.is-demo .mk__row:nth-child(3) {
animation-delay: 240ms;
animation-fill-mode: backwards;
}
.mk.is-demo .mk__cta {
animation-name: nlCta;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes nlRise {
0% {
opacity: 0;
transform: translateY(14px);
}
30% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
88% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 0;
transform: translateY(14px);
}
}
@keyframes nlCta {
0%, 10% {
opacity: 0.35;
transform: translateY(8px);
}
40% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 1;
transform: translateY(0);
}
92% {
opacity: 0.35;
transform: translateY(8px);
}
100% {
opacity: 0.35;
transform: translateY(8px);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__brandmark {
width: 18px;
height: 18px;
}
.mk__title {
font-size: 11px !important;
padding-top: 4px !important;
}
.mk__sub {
font-size: 8px !important;
padding-bottom: 0 !important;
}
.mk__head {
font-size: 9px !important;
}
.mk__num {
width: 14px;
height: 14px;
font-size: 8px;
}
.mk__time {
font-size: 7px !important;
padding: 0 4px !important;
}
.mk__cta {
padding: 0 20px !important;
font-size: 10px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__row,
.mk.is-demo .mk__cta {
animation: none !important;
opacity: 1;
transform: none;
}
}var mk = document.getElementById('mk');
var rows = document.querySelectorAll('.mk__row');
for (var i = 0; i < rows.length; i++) {
rows[i].addEventListener('click', function () {
mk.classList.remove('is-demo');
this.classList.toggle('is-read');
var wasRead = this.classList.contains('is-read');
this.setAttribute('aria-pressed', String(wasRead));
var timeBadge = this.querySelector('.mk__time');
timeBadge.textContent = wasRead ? '읽음' : this.getAttribute('data-time');
});
}
var times = document.querySelectorAll('.mk__time');
for (var j = 0; j < times.length; j++) {
times[j].parentNode.setAttribute('data-time', times[j].textContent);
}import { useState } from "react";
import styles from "./NewsletterDigest.module.scss";
export type NewsletterDigestProps = { duration?: number; easing?: string; color?: string };
const HEADLINES = [
{ head: "굵은 글씨 하나로 화면 순서를 만드는 법", time: "6분" },
{ head: "버튼이 눌렸다고 느껴지는 0.1초의 정체", time: "4분" },
{ head: "카드가 겹치는 첫 화면, 각을 잡는 순서", time: "8분" },
];
export default function NewsletterDigest({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#0a4fe8" }: NewsletterDigestProps) {
const [demo, setDemo] = useState(true);
const [read, setRead] = useState<boolean[]>([false, false, false]);
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>
<span className={styles.brandmark} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>
</span>
◆ Acme 주간편지 <span className={styles.issue}>59호</span>
</div>
<div className={styles.title}>이번 주에 잘 읽힌 3편</div>
<div className={styles.sub}>연 사람이 많은 순서로 담았어요.</div>
<div className={styles.rows}>
{HEADLINES.map((h, i) => (
<button
key={h.head}
type="button"
className={`${styles.row} ${read[i] ? styles.isRead : ""}`}
style={demo ? { animationName: "nlRise", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite", animationDelay: `${i * 120}ms`, animationFillMode: "backwards" } : undefined}
aria-pressed={read[i]}
onClick={() => { setDemo(false); setRead((r) => r.map((v, j) => (j === i ? !v : v))); }}
>
<span className={styles.num} aria-hidden="true">{i + 1}</span>
<span className={styles.head}>{h.head}</span>
<span className={styles.time}>{read[i] ? "읽음" : h.time}</span>
</button>
))}
</div>
<div className={styles.ctawrap}>
<a href="#" role="button" className={styles.cta} style={{ background: "#2a5fd8" }} onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>전체 편지 보기</a>
</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}01 · 뉴스레터 다이제스트 — 측정 누적면적 10.677% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
줄을 누르면 읽음 표시로 바뀌는데, 올라온 뒤로도 한동안 그대로 서 있어 루프의 어느 장면을 잡아도 화면이 비지 않습니다(누적면적 10.68%, 움직인 프레임 13/23). 주간 뉴스레터와 콘텐츠 구독 메일에 씁니다.
// 세 줄이 차례로 올라온다 — 지연이 걸린 줄은 backwards 로 0프레임 번쩍임을 막는다
.mk.is-demo .mk__row {
animation-name: nlRise;
animation-duration: 2s;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mk.is-demo .mk__row:nth-child(2) { animation-delay: 120ms; animation-fill-mode: backwards; }
.mk.is-demo .mk__row:nth-child(3) { animation-delay: 240ms; animation-fill-mode: backwards; }
@keyframes nlRise {
0% { opacity: 0; transform: translateY(14px); }
30% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
88% { opacity: 0; transform: translateY(8px); }
100% { opacity: 0; transform: translateY(14px); }
}
02할인 쿠폰 메일
화면에서 일어나는 일
점선 상자 안의 쿠폰 코드를 누르면 navigator.clipboard.writeText 로 코드가 복사되고 상자 글자가 한 프레임에 「복사됨」으로 바뀌며, 복사 결과는 aria-live 영역이 알립니다. 루프에서는 상자가 한 번 부풀었다 돌아오는 것으로 코드 자리를 알려줍니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td align="center" class="mk__hero">
<span class="mk__badge" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><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>
</td></tr>
<tr><td align="center" class="mk__title">여름 시즌 30% 할인</td></tr>
<tr><td align="center" class="mk__body">코드를 눌러 복사하고 결제창에 그대로 붙여넣으세요.</td></tr>
<tr><td class="mk__couponrow">
<button type="button" class="mk__coupon" id="coupon" aria-label="쿠폰 코드 SUMMER30 복사">
<span class="mk__codelabel" id="codelabel">SUMMER30</span>
<span class="mk__donelabel" aria-hidden="true">복사됨</span>
</button>
</td></tr>
<tr><td align="center" class="mk__ctawrap"><a href="#" role="button" class="mk__cta">지금 쇼핑하러 가기</a></td></tr>
<tr><td align="center" class="mk__live" id="live" aria-live="polite"></td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__hero { padding-top: $sp-1 !important; padding-bottom: 0 !important; }
.mk__badge {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(26px, 8vw, 34px); height: clamp(26px, 8vw, 34px); border-radius: $r-sm;
background: #0a4fe8; color: $subject-cream;
}
.mk__ico { width: 62%; height: 62%; }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.25; color: $subject-ink; padding-top: $sp-1 !important; }
.mk__body { font-weight: 500; font-size: clamp(10px, 3vw, 11px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__couponrow { padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__coupon {
position: relative; display: flex; width: 100%; align-items: center; justify-content: center;
background: rgba(47,109,246,.06); border: 2px dashed rgba(47,109,246,.45); border-radius: $r-control;
cursor: pointer; font-family: inherit; padding: $sp-2 $sp-4;
}
.mk__codelabel, .mk__donelabel {
font-weight: 800; font-size: clamp(14px, 4.4vw, 18px); line-height: 1.1; letter-spacing: .08em; color: #6079b0;
}
.mk__donelabel { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; visibility: hidden; color: $subject-ink; }
.mk__coupon.is-copied .mk__codelabel { visibility: hidden; }
.mk__coupon.is-copied .mk__donelabel { visibility: visible; }
.mk__coupon:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.mk__ctawrap { padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__cta {
display: inline-block; padding: $sp-1 $sp-6; font-weight: 700; font-size: 11px; line-height: 1.6;
font-family: inherit; color: $subject-cream; text-decoration: none; border-radius: $r-control;
background: $subject-ink;
}
.mk__live { font-weight: 600; font-size: 9px; line-height: 1.4; color: rgba(23,20,26,.62); padding: 0 !important; min-height: 12px; }
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 상자가 한 번 커졌다 돌아오고(부드럽게), 글자는 코드→복사됨 로 한 프레임에 컷 (2s 주기, 복귀 85%)
.mk.is-demo .mk__coupon { animation-name: prPop; animation-duration: 2s; animation-timing-function: cubic-bezier(.2, 1.4, .4, 1); animation-iteration-count: infinite; }
.mk.is-demo .mk__codelabel { animation-name: prCode; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__donelabel { animation-name: prDone; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__badge { animation-name: prBeat; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
@keyframes prPop {
0%, 12% { transform: scale(1); }
20% { transform: scale(1.06); }
30%, 100% { transform: scale(1); }
}
@keyframes prCode {
0%, 44% { visibility: visible; }
45%, 84% { visibility: hidden; }
85%, 100% { visibility: visible; }
}
@keyframes prDone {
0%, 44% { visibility: hidden; }
45%, 84% { visibility: visible; }
85%, 100% { visibility: hidden; }
}
@keyframes prBeat {
0%, 40% { transform: scale(1); }
50% { transform: scale(1.12); }
62%, 100% { transform: scale(1); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__badge { width: 22px; height: 22px; }
.mk__title { font-size: 11px !important; padding-top: 0 !important; }
.mk__body { font-size: 8px !important; padding-bottom: 0 !important; }
.mk__coupon { padding: $sp-1 $sp-3 !important; }
.mk__codelabel, .mk__donelabel { font-size: 12px !important; }
.mk__cta { padding: 0 $sp-5 !important; font-size: 10px !important; }
.mk__live { min-height: 0; font-size: 8px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__coupon,
.mk.is-demo .mk__codelabel,
.mk.is-demo .mk__donelabel,
.mk.is-demo .mk__badge { animation: 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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__hero {
padding-top: 4px !important;
padding-bottom: 0 !important;
}
.mk__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: clamp(26px, 8vw, 34px);
height: clamp(26px, 8vw, 34px);
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
}
.mk__ico {
width: 62%;
height: 62%;
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.25;
color: #17141a;
padding-top: 4px !important;
}
.mk__body {
font-weight: 500;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__couponrow {
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__coupon {
position: relative;
display: flex;
width: 100%;
align-items: center;
justify-content: center;
background: rgba(47, 109, 246, 0.06);
border: 2px dashed rgba(47, 109, 246, 0.45);
border-radius: 12px;
cursor: pointer;
font-family: inherit;
padding: 8px 16px;
}
.mk__codelabel, .mk__donelabel {
font-weight: 800;
font-size: clamp(14px, 4.4vw, 18px);
line-height: 1.1;
letter-spacing: 0.08em;
color: #6079b0;
}
.mk__donelabel {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
visibility: hidden;
color: #17141a;
}
.mk__coupon.is-copied .mk__codelabel {
visibility: hidden;
}
.mk__coupon.is-copied .mk__donelabel {
visibility: visible;
}
.mk__coupon:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mk__ctawrap {
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__cta {
display: inline-block;
padding: 4px 24px;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #fff7e6;
text-decoration: none;
border-radius: 12px;
background: #17141a;
}
.mk__live {
font-weight: 600;
font-size: 9px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding: 0 !important;
min-height: 12px;
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .mk__coupon {
animation-name: prPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__codelabel {
animation-name: prCode;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__donelabel {
animation-name: prDone;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__badge {
animation-name: prBeat;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes prPop {
0%, 12% {
transform: scale(1);
}
20% {
transform: scale(1.06);
}
30%, 100% {
transform: scale(1);
}
}
@keyframes prCode {
0%, 44% {
visibility: visible;
}
45%, 84% {
visibility: hidden;
}
85%, 100% {
visibility: visible;
}
}
@keyframes prDone {
0%, 44% {
visibility: hidden;
}
45%, 84% {
visibility: visible;
}
85%, 100% {
visibility: hidden;
}
}
@keyframes prBeat {
0%, 40% {
transform: scale(1);
}
50% {
transform: scale(1.12);
}
62%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__badge {
width: 22px;
height: 22px;
}
.mk__title {
font-size: 11px !important;
padding-top: 0 !important;
}
.mk__body {
font-size: 8px !important;
padding-bottom: 0 !important;
}
.mk__coupon {
padding: 4px 12px !important;
}
.mk__codelabel, .mk__donelabel {
font-size: 12px !important;
}
.mk__cta {
padding: 0 20px !important;
font-size: 10px !important;
}
.mk__live {
min-height: 0;
font-size: 8px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__coupon,
.mk.is-demo .mk__codelabel,
.mk.is-demo .mk__donelabel,
.mk.is-demo .mk__badge {
animation: none !important;
}
}var mk = document.getElementById('mk');
var coupon = document.getElementById('coupon');
var codelabel = document.getElementById('codelabel');
var live = document.getElementById('live');
coupon.addEventListener('click', function () {
mk.classList.remove('is-demo');
coupon.classList.add('is-copied');
live.textContent = '코드를 복사했어요';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText('SUMMER30').then(function () {}, function () {
live.textContent = '복사 실패 — 코드를 직접 선택하세요';
coupon.classList.remove('is-copied');
});
}
});import { useState } from "react";
import styles from "./PromoCoupon.module.scss";
export type PromoCouponProps = { duration?: number; easing?: string; color?: string };
export default function PromoCoupon({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#0a4fe8" }: PromoCouponProps) {
const [demo, setDemo] = useState(true);
const [copied, setCopied] = useState(false);
const [live, setLive] = useState("");
const copyCode = () => {
setDemo(false);
setCopied(true);
setLive("코드를 복사했어요");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText("SUMMER30").then(
() => {},
() => { setLive("복사 실패 — 코드를 직접 선택하세요"); setCopied(false); }
);
}
};
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.hero}>
<span className={styles.badge} style={{ background: color }} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><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>
</div>
<div className={styles.title}>여름 시즌 30% 할인</div>
<div className={styles.body}>코드를 눌러 복사하고 결제창에 그대로 붙여넣으세요.</div>
<div className={styles.couponrow}>
<button
type="button"
className={`${styles.coupon} ${copied ? styles.isCopied : ""}`}
style={demo ? { animationName: "prPop", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite", transitionDuration: `${duration}ms` } : { transitionDuration: `${duration}ms` }}
aria-label="쿠폰 코드 SUMMER30 복사"
onClick={copyCode}
>
<span className={styles.codelabel} style={demo ? { animationName: "prCode", animationDuration: "2s", animationTimingFunction: "steps(1, end)", animationIterationCount: "infinite" } : undefined}>SUMMER30</span>
<span className={styles.donelabel} style={demo ? { animationName: "prDone", animationDuration: "2s", animationTimingFunction: "steps(1, end)", animationIterationCount: "infinite" } : undefined} aria-hidden="true">복사됨</span>
</button>
</div>
<div className={styles.ctawrap}>
<a href="#" role="button" className={styles.cta} onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>지금 쇼핑하러 가기</a>
</div>
<div className={styles.live} aria-live="polite">{live}</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}02 · 할인 쿠폰 메일 — 측정 누적면적 3.929% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
시즌 할인 안내와 첫 구매 쿠폰 발송에 씁니다.
coupon.addEventListener('click', function () {
mk.classList.remove('is-demo');
coupon.classList.add('is-copied');
live.textContent = '코드를 복사했어요';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText('SUMMER30').then(function () {}, function () {
live.textContent = '복사 실패 — 코드를 직접 선택하세요';
coupon.classList.remove('is-copied');
});
}
});
03타임세일 남은 시간 띠
어떤 움직임인가
초 칸 숫자는 겹쳐 놓은 세 장을 steps(1, end) 로 한 프레임에 갈아끼우고, 그 아래 막대가 transform-origin: right 을 기준으로 줄어들어 마감이 다가오는 것을 보여줍니다. 숫자가 컷으로만 바뀌는 덕분에 반투명 유령 장이 없고, 움직인 프레임은 22/23 로 쉬는 장이 한 장뿐입니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td align="center" class="mk__hero">
<span class="mk__badge" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><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>
</td></tr>
<tr><td align="center" class="mk__title">오늘 자정까지, 딱 24시간</td></tr>
<tr><td align="center" class="mk__body">베스트 상품을 최대 40% 깎아 드려요.</td></tr>
<tr><td class="mk__countrow">
<div class="mk__count" role="timer" aria-label="남은 시간 5시간 42분 08초">
<div class="mk__cell">
<span class="mk__digits">05</span>
<span class="mk__unit">시간</span>
</div>
<span class="mk__colon" aria-hidden="true">:</span>
<div class="mk__cell">
<span class="mk__digits">42</span>
<span class="mk__unit">분</span>
</div>
<span class="mk__colon" aria-hidden="true">:</span>
<div class="mk__cell mk__cellsec">
<span class="mk__digits" aria-hidden="true">
<span class="mk__d1">08</span><span class="mk__d2">07</span><span class="mk__d3">06</span>
</span>
<span class="mk__unit">초</span>
</div>
</div>
<div class="mk__bar" aria-hidden="true"><span class="mk__fill"></span></div>
</td></tr>
<tr><td align="center" class="mk__ctawrap">
<a href="#" role="button" class="mk__cta">세일 보러 가기</a>
<button type="button" class="mk__remind" id="remind" aria-pressed="false">마감 알림 받기</button>
</td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-orange);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__hero { padding-top: $sp-1 !important; padding-bottom: 0 !important; }
.mk__badge {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(26px, 8vw, 34px); height: clamp(26px, 8vw, 34px); border-radius: 50%;
background: $stage-orange; color: $subject-ink;
}
.mk__ico { width: 60%; height: 60%; }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.25; color: $subject-ink; padding-top: $sp-1 !important; }
.mk__body { font-weight: 500; font-size: clamp(10px, 3vw, 11px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__countrow { padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__count {
display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; align-items: center; gap: $sp-1;
background: $subject-ink; border-radius: $r-control; padding: $sp-2 $sp-3;
}
.mk__cell { display: flex; flex-direction: column; align-items: center; gap: 0; }
.mk__digits {
position: relative; font-weight: 800; font-size: clamp(18px, 6vw, 24px); line-height: 1;
color: $subject-cream; font-variant-numeric: tabular-nums; letter-spacing: .02em;
}
.mk__unit { font-weight: 600; font-size: 8px; line-height: 1; color: rgba(255,247,230,.55); }
.mk__colon { font-weight: 800; font-size: 14px; line-height: 1; color: rgba($subject-cream, .72); padding-bottom: $sp-2; }
// 초 칸은 겹쳐 놓은 세 장을 steps 로 한 프레임에 교체한다 (교차 페이드 금지 — 유령 프레임)
.mk__cellsec .mk__digits { }
.mk__d1, .mk__d2, .mk__d3 { position: absolute; inset: 0; display: flex; justify-content: center; }
.mk__d2, .mk__d3 { visibility: hidden; }
.mk__bar {
margin-top: $sp-1; height: 4px; border-radius: $r-pill; background: rgba(23,20,26,.1); overflow: hidden;
}
.mk__fill { display: block; height: 100%; border-radius: $r-pill; background: $stage-orange; transform-origin: right; } // 규칙8: 카운트다운 막대 우측 감소
.mk__ctawrap { padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__cta {
display: inline-block; padding: $sp-1 $sp-5; font-weight: 700; font-size: 11px; line-height: 1.6;
font-family: inherit; color: $subject-ink; text-decoration: none; border-radius: $r-control;
background: $stage-orange;
}
.mk__remind {
display: inline-block; padding: $sp-1 $sp-3; margin-left: $sp-2; font-weight: 700; font-size: 10px; line-height: 1.6;
font-family: inherit; color: rgba(23,20,26,.6); background: none; border: 1px solid rgba(23,20,26,.25);
border-radius: $r-pill; cursor: pointer;
}
.mk__remind.is-on { color: $subject-cream; background: $subject-ink; border-color: $subject-ink; }
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 초 숫자는 steps(1,end) 컷 교체, 막대는 transform-origin: right 기준으로 줄어든다 (2s 주기, 복귀 85%)
.mk.is-demo .mk__d1 { animation-name: fsD1; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__d2 { animation-name: fsD2; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__d3 { animation-name: fsD3; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__fill { animation-name: fsBar; animation-duration: 2s; animation-timing-function: linear; animation-iteration-count: infinite; }
.mk.is-demo .mk__badge { animation-name: fsTick; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
@keyframes fsD1 {
0%, 32% { visibility: visible; }
33%, 100% { visibility: hidden; }
}
@keyframes fsD2 {
0%, 32% { visibility: hidden; }
33%, 65% { visibility: visible; }
66%, 100% { visibility: hidden; }
}
@keyframes fsD3 {
0%, 65% { visibility: hidden; }
66%, 84% { visibility: visible; }
85%, 100% { visibility: hidden; }
}
@keyframes fsBar {
0% { transform: scaleX(1); }
78% { transform: scaleX(.16); }
82% { opacity: 0; transform: scaleX(.16); }
84% { opacity: 0; transform: scaleX(1); }
90%, 100% { opacity: 1; transform: scaleX(1); }
}
@keyframes fsTick {
0%, 8% { transform: scale(1); }
14% { transform: scale(1.1); }
22%, 100% { transform: scale(1); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__badge { width: 22px; height: 22px; }
.mk__title { font-size: 11px !important; padding-top: 0 !important; }
.mk__body { font-size: 8px !important; padding-bottom: 0 !important; }
.mk__digits { font-size: 15px !important; }
.mk__unit { font-size: 7px !important; }
.mk__count { padding: $sp-1 $sp-2 !important; }
.mk__cta { padding: 0 $sp-3 !important; font-size: 10px !important; }
.mk__remind { padding: 0 $sp-2 !important; margin-left: $sp-1 !important; font-size: 9px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__d1,
.mk.is-demo .mk__d2,
.mk.is-demo .mk__d3,
.mk.is-demo .mk__fill,
.mk.is-demo .mk__badge { animation: none !important; }
.mk__d1 { 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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__hero {
padding-top: 4px !important;
padding-bottom: 0 !important;
}
.mk__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: clamp(26px, 8vw, 34px);
height: clamp(26px, 8vw, 34px);
border-radius: 50%;
background: #ff4d1f;
color: #17141a;
}
.mk__ico {
width: 60%;
height: 60%;
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.25;
color: #17141a;
padding-top: 4px !important;
}
.mk__body {
font-weight: 500;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__countrow {
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__count {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
align-items: center;
gap: 4px;
background: #17141a;
border-radius: 12px;
padding: 8px 12px;
}
.mk__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.mk__digits {
position: relative;
font-weight: 800;
font-size: clamp(18px, 6vw, 24px);
line-height: 1;
color: #fff7e6;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.mk__unit {
font-weight: 600;
font-size: 8px;
line-height: 1;
color: rgba(255, 247, 230, 0.55);
}
.mk__colon {
font-weight: 800;
font-size: 14px;
line-height: 1;
color: rgba(255, 247, 230, 0.72);
padding-bottom: 8px;
}
.mk__d1, .mk__d2, .mk__d3 {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
}
.mk__d2, .mk__d3 {
visibility: hidden;
}
.mk__bar {
margin-top: 4px;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.mk__fill {
display: block;
height: 100%;
border-radius: 999px;
background: #ff4d1f;
transform-origin: right;
}
.mk__ctawrap {
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__cta {
display: inline-block;
padding: 4px 20px;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #17141a;
text-decoration: none;
border-radius: 12px;
background: #ff4d1f;
}
.mk__remind {
display: inline-block;
padding: 4px 12px;
margin-left: 8px;
font-weight: 700;
font-size: 10px;
line-height: 1.6;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
background: none;
border: 1px solid rgba(23, 20, 26, 0.25);
border-radius: 999px;
cursor: pointer;
}
.mk__remind.is-on {
color: #fff7e6;
background: #17141a;
border-color: #17141a;
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .mk__d1 {
animation-name: fsD1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__d2 {
animation-name: fsD2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__d3 {
animation-name: fsD3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__fill {
animation-name: fsBar;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.mk.is-demo .mk__badge {
animation-name: fsTick;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes fsD1 {
0%, 32% {
visibility: visible;
}
33%, 100% {
visibility: hidden;
}
}
@keyframes fsD2 {
0%, 32% {
visibility: hidden;
}
33%, 65% {
visibility: visible;
}
66%, 100% {
visibility: hidden;
}
}
@keyframes fsD3 {
0%, 65% {
visibility: hidden;
}
66%, 84% {
visibility: visible;
}
85%, 100% {
visibility: hidden;
}
}
@keyframes fsBar {
0% {
transform: scaleX(1);
}
78% {
transform: scaleX(0.16);
}
82% {
opacity: 0;
transform: scaleX(0.16);
}
84% {
opacity: 0;
transform: scaleX(1);
}
90%, 100% {
opacity: 1;
transform: scaleX(1);
}
}
@keyframes fsTick {
0%, 8% {
transform: scale(1);
}
14% {
transform: scale(1.1);
}
22%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__badge {
width: 22px;
height: 22px;
}
.mk__title {
font-size: 11px !important;
padding-top: 0 !important;
}
.mk__body {
font-size: 8px !important;
padding-bottom: 0 !important;
}
.mk__digits {
font-size: 15px !important;
}
.mk__unit {
font-size: 7px !important;
}
.mk__count {
padding: 4px 8px !important;
}
.mk__cta {
padding: 0 12px !important;
font-size: 10px !important;
}
.mk__remind {
padding: 0 8px !important;
margin-left: 4px !important;
font-size: 9px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__d1,
.mk.is-demo .mk__d2,
.mk.is-demo .mk__d3,
.mk.is-demo .mk__fill,
.mk.is-demo .mk__badge {
animation: none !important;
}
.mk__d1 {
visibility: visible;
}
}var mk = document.getElementById('mk');
var remind = document.getElementById('remind');
remind.addEventListener('click', function () {
mk.classList.remove('is-demo');
var on = remind.classList.toggle('is-on');
remind.setAttribute('aria-pressed', String(on));
remind.textContent = on ? '알림 켜짐' : '마감 알림 받기';
});import { useState } from "react";
import styles from "./FlashSaleCountdown.module.scss";
export type FlashSaleCountdownProps = { duration?: number; easing?: string; color?: string };
export default function FlashSaleCountdown({ duration = 300, easing = "ease-out", color = "#ff4d1f" }: FlashSaleCountdownProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState(false);
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.hero}>
<span className={styles.badge} style={{ background: color }} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><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>
</div>
<div className={styles.title}>오늘 자정까지, 딱 24시간</div>
<div className={styles.body}>베스트 상품을 최대 40% 깎아 드려요.</div>
<div className={styles.countrow}>
<div className={styles.count} role="timer" aria-label="남은 시간 5시간 42분 08초">
<div className={styles.cell}>
<span className={styles.digits}>05</span>
<span className={styles.unit}>시간</span>
</div>
<span className={styles.colon} aria-hidden="true">:</span>
<div className={styles.cell}>
<span className={styles.digits}>42</span>
<span className={styles.unit}>분</span>
</div>
<span className={styles.colon} aria-hidden="true">:</span>
<div className={styles.cell}>
<span className={styles.digits} aria-hidden="true">
<span className={styles.d1} style={demo ? { animationName: "fsD1", animationDuration: "2s", animationTimingFunction: "steps(1, end)", animationIterationCount: "infinite" } : undefined}>08</span>
<span className={styles.d2} style={demo ? { animationName: "fsD2", animationDuration: "2s", animationTimingFunction: "steps(1, end)", animationIterationCount: "infinite" } : undefined}>07</span>
<span className={styles.d3} style={demo ? { animationName: "fsD3", animationDuration: "2s", animationTimingFunction: "steps(1, end)", animationIterationCount: "infinite" } : undefined}>06</span>
</span>
<span className={styles.unit}>초</span>
</div>
</div>
<div className={styles.bar} aria-hidden="true">
<span className={styles.fill} style={{ background: color, ...(demo ? { animationName: "fsBar", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite", transformOrigin: "right" } : { transformOrigin: "right", transitionDuration: `${duration}ms` }) }}></span>
</div>
</div>
<div className={styles.ctawrap}>
<a href="#" role="button" className={styles.cta} style={{ background: color }} onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>세일 보러 가기</a>
<button type="button" className={`${styles.remind} ${on ? styles.isOn : ""}`} aria-pressed={on} onClick={() => { setDemo(false); setOn((v) => !v); }}>{on ? "알림 켜짐" : "마감 알림 받기"}</button>
</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}03 · 타임세일 남은 시간 띠 — 측정 누적면적 1.715% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
타임세일 안내와 마감 임박 리마인드에 씁니다.
// 초 칸 숫자 교체 — 겹쳐 놓은 세 장을 steps 컷으로만 바꾼다 (교차 페이드 금지)
.mk.is-demo .mk__d1 { animation-name: fsD1; animation-duration: 2s;
animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__d2 { animation-name: fsD2; animation-duration: 2s;
animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__d3 { animation-name: fsD3; animation-duration: 2s;
animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__fill { animation-name: fsBar; animation-duration: 2s;
animation-timing-function: linear; animation-iteration-count: infinite; }
@keyframes fsBar {
0% { transform: scaleX(1); }
78% { transform: scaleX(.16); }
82% { opacity: 0; transform: scaleX(.16); }
84% { opacity: 0; transform: scaleX(1); }
90%, 100% { opacity: 1; transform: scaleX(1); }
}
04장바구니 이탈 리마인드
무엇이 보이나
담아둔 상품 두 줄이 왼쪽에서 밀려 들어오고, 합계가 한 프레임에 0원에서 41,600원으로 갈립니다. 수량 단추를 누르면 스크립트가 합계를 다시 계산해 toLocaleString 천 단위 표기로 바로 따라오고, 상품 두 줄이 슬라이드로 들어오는 동안 누적면적 8.11% 를 움직입니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td align="center" class="mk__hero">
<span class="mk__badge" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M232,72l-25.63,92.28A16,16,0,0,1,191,176H92.16a16,16,0,0,1-15.41-11.72L51.11,72Z" opacity="0.2"/><path d="M104,216a16,16,0,1,1-16-16A16,16,0,0,1,104,216Zm88-16a16,16,0,1,0,16,16A16,16,0,0,0,192,200ZM239.71,74.14l-25.64,92.28A24.06,24.06,0,0,1,191,184H92.16A24.06,24.06,0,0,1,69,166.42L33.92,40H16a8,8,0,0,1,0-16H40a8,8,0,0,1,7.71,5.86L57.19,64H232a8,8,0,0,1,7.71,10.14ZM221.47,80H61.64l22.81,82.14A8,8,0,0,0,92.16,168H191a8,8,0,0,0,7.71-5.86Z"/></svg></span>
</td></tr>
<tr><td align="center" class="mk__title">담아둔 상품이 기다리고 있어요</td></tr>
<tr><td align="center" class="mk__body">수량을 바꾸면 합계가 바로 따라옵니다.</td></tr>
<tr><td class="mk__items">
<div class="mk__item" data-price="28900">
<span class="mk__thumb mk__thumba" aria-hidden="true"></span>
<span class="mk__meta">
<span class="mk__name">주방 칼 다이 스탠드</span>
<span class="mk__opt">크림 · 1개 입고 예정</span>
</span>
<span class="mk__qty">
<button type="button" class="mk__step" data-dir="-1" aria-label="수량 줄이기">−</button>
<span class="mk__n">1</span>
<button type="button" class="mk__step" data-dir="1" aria-label="수량 늘리기">+</button>
</span>
<span class="mk__price" data-unit="28,900원">28,900원</span>
</div>
<div class="mk__item" data-price="12700">
<span class="mk__thumb mk__thumbb" aria-hidden="true"></span>
<span class="mk__meta">
<span class="mk__name">리넨 키친 타올 3장</span>
<span class="mk__opt">그린 · 재고 4개</span>
</span>
<span class="mk__qty">
<button type="button" class="mk__step" data-dir="-1" aria-label="수량 줄이기">−</button>
<span class="mk__n">1</span>
<button type="button" class="mk__step" data-dir="1" aria-label="수량 늘리기">+</button>
</span>
<span class="mk__price" data-unit="12,700원">12,700원</span>
</div>
<div class="mk__total">
<span class="mk__totallabel">합계</span>
<span class="mk__totalval">
<span class="mk__t0">0원</span>
<span class="mk__t1" id="total">41,600원</span>
</span>
</div>
</td></tr>
<tr><td align="center" class="mk__ctawrap"><a href="#" role="button" class="mk__cta">구매 계속하기</a></td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-paper);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; border: 1px solid rgba(23,20,26,.1); overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__hero { padding-top: $sp-1 !important; padding-bottom: 0 !important; }
.mk__badge {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(26px, 8vw, 34px); height: clamp(26px, 8vw, 34px); border-radius: $r-sm;
background: #0a4fe8; color: $subject-cream;
}
.mk__ico { width: 62%; height: 62%; }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.25; color: $subject-ink; padding-top: $sp-1 !important; }
.mk__body { font-weight: 500; font-size: clamp(10px, 3vw, 11px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__items { padding-top: 0 !important; padding-bottom: 0 !important; }
.mk__item {
display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: $sp-2;
padding: $sp-1 0; border-top: 1px solid rgba(0,0,0,.06);
}
.mk__thumb { width: clamp(22px, 6vw, 28px); height: clamp(22px, 6vw, 28px); border-radius: $r-xs; }
.mk__thumba { background: linear-gradient(135deg, $subject-lilac, $subject-sky); }
.mk__thumbb { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
.mk__meta { display: flex; flex-direction: column; gap: 0; min-width: 0; }
.mk__name { font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1.2; color: $subject-ink; }
.mk__opt { font-weight: 500; font-size: 8px; line-height: 1.3; color: rgba(23,20,26,.62); }
.mk__qty { display: inline-flex; align-items: center; gap: $sp-1; }
.mk__step {
width: 18px; height: 18px; border-radius: 50%; border: 1px solid rgba(23,20,26,.22);
background: none; cursor: pointer; font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1;
color: rgba(23,20,26,.65); padding: 0;
}
.mk__step:hover { background: rgba(23,20,26,.06); }
.mk__n { font-weight: 800; font-size: 11px; line-height: 1; color: $subject-ink; min-width: 12px; text-align: center; }
.mk__price { font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1; color: $subject-ink; font-variant-numeric: tabular-nums; }
.mk__total {
display: flex; align-items: center; justify-content: space-between;
padding: $sp-2 0 $sp-1; border-top: 1px solid rgba(0,0,0,.06);
}
.mk__totallabel { font-weight: 700; font-size: 10px; line-height: 1; color: rgba(23,20,26,.62); }
.mk__totalval { position: relative; font-weight: 800; font-size: clamp(12px, 3.8vw, 15px); line-height: 1; color: $subject-ink; font-variant-numeric: tabular-nums; }
.mk__t0 { position: absolute; right: 0; top: 0; visibility: hidden; }
.mk__t1 { }
.mk__ctawrap { padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__cta {
display: inline-block; padding: $sp-1 $sp-6; font-weight: 700; font-size: 11px; line-height: 1.6;
font-family: inherit; color: $subject-cream; text-decoration: none; border-radius: $r-control;
background: #7c90be; // CTA 파랑을 어둡게 — 크림 글자 4.25:1 미달이라 5.29:1 로 (279 선례)
}
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 상품 줄이 왼쪽에서 밀려 들어오고, 합계는 0원→41,600원 으로 한 프레임에 컷 (2s 주기, 복귀 86%)
.mk.is-demo .mk__item { animation-name: acSlide; animation-duration: 2s; animation-timing-function: $easing; animation-iteration-count: infinite; }
.mk.is-demo .mk__item:nth-child(2) { animation-delay: 140ms; animation-fill-mode: backwards; }
.mk.is-demo .mk__t0 { animation-name: acT0; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__t1 { animation-name: acT1; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__cta { animation-name: acCta; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
@keyframes acSlide {
0% { opacity: 0; transform: translateX(-16px); }
32% { opacity: 1; transform: translateX(0); }
80% { opacity: 1; transform: translateX(0); }
86% { opacity: 0; transform: translateX(-8px); }
100% { opacity: 0; transform: translateX(-16px); }
}
@keyframes acT0 {
0%, 44% { visibility: visible; }
45%, 85% { visibility: hidden; }
86%, 100% { visibility: visible; }
}
@keyframes acT1 {
0%, 44% { visibility: hidden; }
45%, 85% { visibility: visible; }
86%, 100% { visibility: hidden; }
}
@keyframes acCta {
0%, 42% { opacity: .3; transform: translateY(8px); }
70% { opacity: 1; transform: translateY(0); }
84% { opacity: 1; transform: translateY(0); }
90%, 100% { opacity: .3; transform: translateY(8px); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__badge { width: 22px; height: 22px; }
.mk__title { font-size: 11px !important; padding-top: 0 !important; }
.mk__body { font-size: 8px !important; padding-bottom: 0 !important; }
.mk__thumb { width: 18px; height: 18px; }
.mk__name { font-size: 9px !important; }
.mk__opt { font-size: 7px !important; }
.mk__step { width: 14px; height: 14px; font-size: 9px; }
.mk__price { font-size: 9px !important; }
.mk__total { padding-top: $sp-1 !important; padding-bottom: 0 !important; }
.mk__totalval { font-size: 11px !important; }
.mk__cta { padding: 0 $sp-5 !important; font-size: 10px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__item,
.mk.is-demo .mk__t0,
.mk.is-demo .mk__t1,
.mk.is-demo .mk__cta { animation: none !important; opacity: 1; transform: 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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 1px solid rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__hero {
padding-top: 4px !important;
padding-bottom: 0 !important;
}
.mk__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: clamp(26px, 8vw, 34px);
height: clamp(26px, 8vw, 34px);
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
}
.mk__ico {
width: 62%;
height: 62%;
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.25;
color: #17141a;
padding-top: 4px !important;
}
.mk__body {
font-weight: 500;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__items {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mk__item {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 8px;
padding: 4px 0;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.mk__thumb {
width: clamp(22px, 6vw, 28px);
height: clamp(22px, 6vw, 28px);
border-radius: 4px;
}
.mk__thumba {
background: linear-gradient(135deg, #b9a0ff, #6ec8ff);
}
.mk__thumbb {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.mk__meta {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.mk__name {
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.2;
color: #17141a;
}
.mk__opt {
font-weight: 500;
font-size: 8px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.62);
}
.mk__qty {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mk__step {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid rgba(23, 20, 26, 0.22);
background: none;
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.65);
padding: 0;
}
.mk__step:hover {
background: rgba(23, 20, 26, 0.06);
}
.mk__n {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: #17141a;
min-width: 12px;
text-align: center;
}
.mk__price {
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.mk__total {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0 4px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.mk__totallabel {
font-weight: 700;
font-size: 10px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
}
.mk__totalval {
position: relative;
font-weight: 800;
font-size: clamp(12px, 3.8vw, 15px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.mk__t0 {
position: absolute;
right: 0;
top: 0;
visibility: hidden;
}
.mk__ctawrap {
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__cta {
display: inline-block;
padding: 4px 24px;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #fff7e6;
text-decoration: none;
border-radius: 12px;
background: #7c90be;
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .mk__item {
animation-name: acSlide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__item:nth-child(2) {
animation-delay: 140ms;
animation-fill-mode: backwards;
}
.mk.is-demo .mk__t0 {
animation-name: acT0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__t1 {
animation-name: acT1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__cta {
animation-name: acCta;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes acSlide {
0% {
opacity: 0;
transform: translateX(-16px);
}
32% {
opacity: 1;
transform: translateX(0);
}
80% {
opacity: 1;
transform: translateX(0);
}
86% {
opacity: 0;
transform: translateX(-8px);
}
100% {
opacity: 0;
transform: translateX(-16px);
}
}
@keyframes acT0 {
0%, 44% {
visibility: visible;
}
45%, 85% {
visibility: hidden;
}
86%, 100% {
visibility: visible;
}
}
@keyframes acT1 {
0%, 44% {
visibility: hidden;
}
45%, 85% {
visibility: visible;
}
86%, 100% {
visibility: hidden;
}
}
@keyframes acCta {
0%, 42% {
opacity: 0.3;
transform: translateY(8px);
}
70% {
opacity: 1;
transform: translateY(0);
}
84% {
opacity: 1;
transform: translateY(0);
}
90%, 100% {
opacity: 0.3;
transform: translateY(8px);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__badge {
width: 22px;
height: 22px;
}
.mk__title {
font-size: 11px !important;
padding-top: 0 !important;
}
.mk__body {
font-size: 8px !important;
padding-bottom: 0 !important;
}
.mk__thumb {
width: 18px;
height: 18px;
}
.mk__name {
font-size: 9px !important;
}
.mk__opt {
font-size: 7px !important;
}
.mk__step {
width: 14px;
height: 14px;
font-size: 9px;
}
.mk__price {
font-size: 9px !important;
}
.mk__total {
padding-top: 4px !important;
padding-bottom: 0 !important;
}
.mk__totalval {
font-size: 11px !important;
}
.mk__cta {
padding: 0 20px !important;
font-size: 10px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__item,
.mk.is-demo .mk__t0,
.mk.is-demo .mk__t1,
.mk.is-demo .mk__cta {
animation: none !important;
opacity: 1;
transform: none;
}
}var mk = document.getElementById('mk');
var total = document.getElementById('total');
// 금액 표기는 페이지 언어를 따라간다 (ko 원 · en ₩ · ja ウォン) — 문구 사전이 바꾼 글자와 어긋나지 않게
function money(n) {
var lang = document.documentElement.lang || 'ko';
var digits = n.toLocaleString('ko-KR');
if (lang === 'ja') { return digits + 'ウォン'; }
if (lang === 'en') { return '₩' + digits; }
return digits + '원';
}
function recompute() {
var items = document.querySelectorAll('.mk__item');
var sum = 0;
for (var i = 0; i < items.length; i++) {
var qty = parseInt(items[i].querySelector('.mk__n').textContent, 10) || 0;
sum += qty * parseInt(items[i].getAttribute('data-price'), 10);
}
total.textContent = money(sum);
}
var steps = document.querySelectorAll('.mk__step');
for (var s = 0; s < steps.length; s++) {
steps[s].addEventListener('click', function () {
mk.classList.remove('is-demo');
var item = this.closest('.mk__item');
var n = item.querySelector('.mk__n');
var next = (parseInt(n.textContent, 10) || 0) + parseInt(this.getAttribute('data-dir'), 10);
if (next < 1) { next = 1; }
if (next > 9) { next = 9; }
n.textContent = String(next);
item.querySelector('.mk__price').textContent = money(next * parseInt(item.getAttribute('data-price'), 10));
recompute();
});
}import { useState } from "react";
import styles from "./AbandonedCart.module.scss";
export type AbandonedCartProps = { duration?: number; easing?: string; color?: string };
type Item = { name: string; opt: string; price: number; thumb: string };
const ITEMS: Item[] = [
{ name: "주방 칼 다이 스탠드", opt: "크림 · 1개 입고 예정", price: 28900, thumb: "a" },
{ name: "리넨 키친 타올 3장", opt: "그린 · 재고 4개", price: 12700, thumb: "b" },
];
function money(n: number): string {
const lang = document.documentElement.lang || "ko";
const digits = n.toLocaleString("ko-KR");
if (lang === "ja") { return digits + "ウォン"; }
if (lang === "en") { return "₩" + digits; }
return digits + "원";
}
export default function AbandonedCart({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#0a4fe8" }: AbandonedCartProps) {
const [demo, setDemo] = useState(true);
const [qtys, setQtys] = useState<number[]>([1, 1]);
const total = qtys.reduce((sum, q, i) => sum + q * ITEMS[i].price, 0);
const step = (i: number, dir: number) => {
setDemo(false);
setQtys((qs) => qs.map((q, j) => (j === i ? Math.min(9, Math.max(1, q + dir)) : q)));
};
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.hero}>
<span className={styles.badge} style={{ background: color }} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M232,72l-25.63,92.28A16,16,0,0,1,191,176H92.16a16,16,0,0,1-15.41-11.72L51.11,72Z" opacity="0.2"/><path d="M104,216a16,16,0,1,1-16-16A16,16,0,0,1,104,216Zm88-16a16,16,0,1,0,16,16A16,16,0,0,0,192,200ZM239.71,74.14l-25.64,92.28A24.06,24.06,0,0,1,191,184H92.16A24.06,24.06,0,0,1,69,166.42L33.92,40H16a8,8,0,0,1,0-16H40a8,8,0,0,1,7.71,5.86L57.19,64H232a8,8,0,0,1,7.71,10.14ZM221.47,80H61.64l22.81,82.14A8,8,0,0,0,92.16,168H191a8,8,0,0,0,7.71-5.86Z"/></svg>
</span>
</div>
<div className={styles.title}>담아둔 상품이 기다리고 있어요</div>
<div className={styles.body}>수량을 바꾸면 합계가 바로 따라옵니다.</div>
<div className={styles.items}>
{ITEMS.map((it, i) => (
<div key={it.name} className={styles.item} style={demo ? { animationName: "acSlide", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite", animationDelay: `${i * 140}ms`, animationFillMode: "backwards" } : undefined}>
<span className={`${styles.thumb} ${it.thumb === "a" ? styles.thumbA : styles.thumbB}`} aria-hidden="true"></span>
<span className={styles.meta}>
<span className={styles.name}>{it.name}</span>
<span className={styles.opt}>{it.opt}</span>
</span>
<span className={styles.qty}>
<button type="button" className={styles.stepBtn} aria-label="수량 줄이기" onClick={() => step(i, -1)}>−</button>
<span className={styles.n}>{qtys[i]}</span>
<button type="button" className={styles.stepBtn} aria-label="수량 늘리기" onClick={() => step(i, 1)}>+</button>
</span>
<span className={styles.price}>{money(qtys[i] * it.price)}</span>
</div>
))}
<div className={styles.total}>
<span className={styles.totalLabel}>합계</span>
<span className={styles.totalVal}>{money(total)}</span>
</div>
</div>
<div className={styles.ctawrap}>
<a href="#" role="button" className={styles.cta} style={{ background: "#2a5fd8" }} onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>구매 계속하기</a>
</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}04 · 장바구니 이탈 리마인드 — 측정 누적면적 8.108% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
쇼핑몰 장바구니 리마인드와 재고 알림에 씁니다.
// 금액 표기는 페이지 언어를 따라간다 (ko 원 · en ₩ · ja ウォン)
function money(n) {
var lang = document.documentElement.lang || 'ko';
var digits = n.toLocaleString('ko-KR');
if (lang === 'ja') { return digits + 'ウォン'; }
if (lang === 'en') { return '₩' + digits; }
return digits + '원';
}
function recompute() {
var items = document.querySelectorAll('.mk__item');
var sum = 0;
for (var i = 0; i < items.length; i++) {
var qty = parseInt(items[i].querySelector('.mk__n').textContent, 10) || 0;
sum += qty * parseInt(items[i].getAttribute('data-price'), 10);
}
total.textContent = money(sum);
}
05행사 초대 · 참석 여부
화면에서 일어나는 일
왼쪽 날짜 카드 옆에 참석·미정·불참 버튼이 서고, 루프에서 참석 버튼이 눌린 상태로 컷 전환되며 체크 아이콘이 튀어 오릅니다. 버튼 세 개는 실제로 눌리며 aria-pressed 로 선택을 알리고, 다른 것을 누르면 앞의 선택이 풀립니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td class="mk__evrow">
<div class="mk__datecard" aria-hidden="true">
<span class="mk__month">9월</span>
<span class="mk__day">24</span>
<span class="mk__weekday">목요일</span>
</div>
<div class="mk__evinfo">
<span class="mk__evtitle">가을 정기 모임, 두 번째</span>
<span class="mk__meta">온라인 웨비나 · 19:30</span>
<span class="mk__meta">발표 2편 + 질문 30분</span>
</div>
</td></tr>
<tr><td class="mk__title">참석하시겠어요?</td></tr>
<tr><td align="center" class="mk__rsvp">
<button type="button" class="mk__opt mk__yes" aria-pressed="false">
<span class="mk__check" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><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>
<button type="button" class="mk__opt" aria-pressed="false">미정</button>
<button type="button" class="mk__opt" aria-pressed="false">불참</button>
</td></tr>
<tr><td align="center" class="mk__body">누르면 답장으로 확정 링크를 보내드려요.</td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $stage-orange;
@include stage($stage-ink);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__evrow { display: flex; align-items: center; gap: $sp-3; padding-top: $sp-1 !important; }
.mk__datecard {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0;
width: clamp(52px, 16vw, 64px); height: clamp(52px, 16vw, 64px);
background: $stage-orange; border-radius: $r-control; color: $subject-ink;
}
.mk__month { font-weight: 700; font-size: 9px; line-height: 1.2; }
.mk__day { font-weight: 800; font-size: clamp(18px, 6vw, 22px); line-height: 1; }
.mk__weekday { font-weight: 600; font-size: 8px; line-height: 1.2; }
.mk__evinfo { display: flex; flex-direction: column; gap: $sp-1; min-width: 0; }
.mk__evtitle { font-weight: 800; font-size: clamp(12px, 3.8vw, 14px); line-height: 1.25; color: $subject-ink; }
.mk__meta { font-weight: 500; font-size: 9px; line-height: 1.3; color: rgba(23,20,26,.62); }
.mk__title { font-weight: 800; font-size: clamp(11px, 3.4vw, 13px); line-height: 1.3; color: $subject-ink; padding-top: $sp-2 !important; }
.mk__rsvp { display: flex; justify-content: center; gap: $sp-2; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__opt {
display: inline-flex; align-items: center; justify-content: center; gap: $sp-1;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.6;
padding: $sp-1 $sp-4; border-radius: $r-pill; cursor: pointer;
color: rgba(23,20,26,.65); background: rgba(23,20,26,.05); border: 1px solid rgba(23,20,26,.16);
}
.mk__opt:hover { background: rgba(23,20,26,.09); }
.mk__check { display: none; line-height: 0; }
.mk__ico { width: 13px; height: 13px; }
.mk__opt.is-on {
color: $subject-cream; background: $subject-ink; border-color: $subject-ink;
}
.mk__opt.is-on .mk__check { display: inline-flex; }
.mk__opt:focus-visible { outline: 2px solid $stage-orange; outline-offset: 2px; }
.mk__body { font-weight: 500; font-size: clamp(9px, 2.8vw, 10px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; }
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 참석 버튼이 눌린 상태로 한 프레임에 컷 전환되고 체크가 튀어 오른다 (2s 주기, 복귀 85%)
// 실제 클릭은 부드러운 전환(is-on 클래스) — 컷은 격자 미리보기 루프에만 쓴다
.mk.is-demo .mk__yes { animation-name: evYes; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__yes .mk__check { display: inline-flex; }
.mk.is-demo .mk__yes .mk__check { animation-name: evCheck; animation-duration: 2s; animation-timing-function: cubic-bezier(.2, 1.4, .4, 1); animation-iteration-count: infinite; }
.mk.is-demo .mk__datecard { animation-name: evTilt; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
@keyframes evYes {
0%, 34% { color: rgba(23,20,26,.65); background: rgba(23,20,26,.05); border-color: rgba(23,20,26,.16); }
35%, 84% { color: $subject-cream; background: $subject-ink; border-color: $subject-ink; }
85%, 100% { color: rgba(23,20,26,.65); background: rgba(23,20,26,.05); border-color: rgba(23,20,26,.16); }
}
@keyframes evCheck {
0%, 34% { transform: scale(0); }
44% { transform: scale(1.25); }
52%, 84% { transform: scale(1); }
85%, 100% { transform: scale(0); }
}
@keyframes evTilt {
0%, 10% { transform: rotate(0deg) scale(1); }
16% { transform: rotate(-3deg) scale(1.05); }
24%, 100% { transform: rotate(0deg) scale(1); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__datecard { width: 44px; height: 44px; }
.mk__day { font-size: 15px !important; }
.mk__evtitle { font-size: 10px !important; }
.mk__meta { font-size: 8px !important; }
.mk__evrow { padding-top: $sp-1 !important; }
.mk__title { padding-top: $sp-1 !important; font-size: 10px !important; }
.mk__opt { padding: 0 $sp-3 !important; font-size: 10px !important; }
.mk__body { font-size: 8px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__yes,
.mk.is-demo .mk__yes .mk__check,
.mk.is-demo .mk__datecard { animation: none !important; }
.mk.is-demo .mk__yes .mk__check { transform: scale(1); }
}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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__evrow {
display: flex;
align-items: center;
gap: 12px;
padding-top: 4px !important;
}
.mk__datecard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
width: clamp(52px, 16vw, 64px);
height: clamp(52px, 16vw, 64px);
background: #ff4d1f;
border-radius: 12px;
color: #17141a;
}
.mk__month {
font-weight: 700;
font-size: 9px;
line-height: 1.2;
}
.mk__day {
font-weight: 800;
font-size: clamp(18px, 6vw, 22px);
line-height: 1;
}
.mk__weekday {
font-weight: 600;
font-size: 8px;
line-height: 1.2;
}
.mk__evinfo {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.mk__evtitle {
font-weight: 800;
font-size: clamp(12px, 3.8vw, 14px);
line-height: 1.25;
color: #17141a;
}
.mk__meta {
font-weight: 500;
font-size: 9px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.62);
}
.mk__title {
font-weight: 800;
font-size: clamp(11px, 3.4vw, 13px);
line-height: 1.3;
color: #17141a;
padding-top: 8px !important;
}
.mk__rsvp {
display: flex;
justify-content: center;
gap: 8px;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__opt {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
padding: 4px 16px;
border-radius: 999px;
cursor: pointer;
color: rgba(23, 20, 26, 0.65);
background: rgba(23, 20, 26, 0.05);
border: 1px solid rgba(23, 20, 26, 0.16);
}
.mk__opt:hover {
background: rgba(23, 20, 26, 0.09);
}
.mk__check {
display: none;
line-height: 0;
}
.mk__ico {
width: 13px;
height: 13px;
}
.mk__opt.is-on {
color: #fff7e6;
background: #17141a;
border-color: #17141a;
}
.mk__opt.is-on .mk__check {
display: inline-flex;
}
.mk__opt:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.mk__body {
font-weight: 500;
font-size: clamp(9px, 2.8vw, 10px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .mk__yes {
animation-name: evYes;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__yes .mk__check {
display: inline-flex;
}
.mk.is-demo .mk__yes .mk__check {
animation-name: evCheck;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__datecard {
animation-name: evTilt;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes evYes {
0%, 34% {
color: rgba(23, 20, 26, 0.65);
background: rgba(23, 20, 26, 0.05);
border-color: rgba(23, 20, 26, 0.16);
}
35%, 84% {
color: #fff7e6;
background: #17141a;
border-color: #17141a;
}
85%, 100% {
color: rgba(23, 20, 26, 0.65);
background: rgba(23, 20, 26, 0.05);
border-color: rgba(23, 20, 26, 0.16);
}
}
@keyframes evCheck {
0%, 34% {
transform: scale(0);
}
44% {
transform: scale(1.25);
}
52%, 84% {
transform: scale(1);
}
85%, 100% {
transform: scale(0);
}
}
@keyframes evTilt {
0%, 10% {
transform: rotate(0deg) scale(1);
}
16% {
transform: rotate(-3deg) scale(1.05);
}
24%, 100% {
transform: rotate(0deg) scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__datecard {
width: 44px;
height: 44px;
}
.mk__day {
font-size: 15px !important;
}
.mk__evtitle {
font-size: 10px !important;
}
.mk__meta {
font-size: 8px !important;
}
.mk__evrow {
padding-top: 4px !important;
}
.mk__title {
padding-top: 4px !important;
font-size: 10px !important;
}
.mk__opt {
padding: 0 12px !important;
font-size: 10px !important;
}
.mk__body {
font-size: 8px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__yes,
.mk.is-demo .mk__yes .mk__check,
.mk.is-demo .mk__datecard {
animation: none !important;
}
.mk.is-demo .mk__yes .mk__check {
transform: scale(1);
}
}var mk = document.getElementById('mk');
var opts = document.querySelectorAll('.mk__opt');
for (var i = 0; i < opts.length; i++) {
opts[i].addEventListener('click', function () {
mk.classList.remove('is-demo');
var on = this.classList.contains('is-on');
for (var j = 0; j < opts.length; j++) {
opts[j].classList.remove('is-on');
opts[j].setAttribute('aria-pressed', 'false');
}
if (!on) {
this.classList.add('is-on');
this.setAttribute('aria-pressed', 'true');
}
});
}import { useState } from "react";
import styles from "./EventInvite.module.scss";
export type EventInviteProps = { duration?: number; easing?: string; color?: string };
export default function EventInvite({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#ff4d1f" }: EventInviteProps) {
const [demo, setDemo] = useState(true);
const [pick, setPick] = useState(-1);
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.evrow}>
<div className={styles.datecard} style={{ background: color }} aria-hidden="true">
<span className={styles.month}>9월</span>
<span className={styles.day}>24</span>
<span className={styles.weekday}>목요일</span>
</div>
<div className={styles.evinfo}>
<span className={styles.evtitle}>가을 정기 모임, 두 번째</span>
<span className={styles.meta}>온라인 웨비나 · 19:30</span>
<span className={styles.meta}>발표 2편 + 질문 30분</span>
</div>
</div>
<div className={styles.title}>참석하시겠어요?</div>
<div className={styles.rsvp}>
{["참석", "미정", "불참"].map((label, i) => (
<button
key={label}
type="button"
className={`${styles.opt} ${i === 0 ? styles.yes : ""} ${pick === i ? styles.isOn : ""}`}
style={demo && i === 0 ? { animationName: "evYes", animationDuration: "2s", animationTimingFunction: "steps(1, end)", animationIterationCount: "infinite", transitionDuration: `${duration}ms` } : { transitionDuration: `${duration}ms` }}
aria-pressed={pick === i}
onClick={() => { setDemo(false); setPick((p) => (p === i ? -1 : i)); }}
>
{i === 0 ? (
<span className={styles.check} style={demo ? { animationName: "evCheck", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite" } : undefined} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><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>
) : null}
{label}
</button>
))}
</div>
<div className={styles.body}>누르면 답장으로 확정 링크를 보내드려요.</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}05 · 행사 초대 · 참석 여부 — 측정 누적면적 2.231% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
웨비나·오프라인 행사 초대와 예약 확정 요청에 씁니다.
// 참석 버튼 컷 전환 — 실제 클릭은 부드러운 is-on 전환, 컷은 격자 미리보기 루프에만
.mk.is-demo .mk__yes {
animation-name: evYes;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__yes .mk__check {
animation-name: evCheck;
animation-duration: 2s;
animation-timing-function: cubic-bezier(.2, 1.4, .4, 1);
animation-iteration-count: infinite;
}
@keyframes evCheck {
0%, 34% { transform: scale(0); }
44% { transform: scale(1.25); }
52%, 84% { transform: scale(1); }
85%, 100% { transform: scale(0); }
}
06온보딩 체크리스트
어떤 움직임인가
다섯 칸 진행 막대가 세 칸까지 차 있고, 네 번째 줄의 체크 표시가 stroke-dashoffset 으로 선을 따라 그려집니다. 네 번째 줄을 누르면 막대 네 칸이 실제로 차오르고 aria-valuenow 가 60 에서 80 으로 바뀌며, 진행 막대는 게이지 규칙대로 grid-template-columns: repeat(5, 1fr) 로 균등하게 짜서 칸이 밀리지 않습니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td align="center" class="mk__title">반가워요! 시작할 준비가 거의 끝났어요</td></tr>
<tr><td align="center" class="mk__body">5단계 중 3단계를 끝냈어요. 아래 칸을 눌러 다음 단계를 끝내 보세요.</td></tr>
<tr><td class="mk__barrow">
<div class="mk__bar" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" aria-label="시작 단계 진행률" id="bar">
<span class="mk__seg is-fill"></span><span class="mk__seg is-fill"></span><span class="mk__seg is-fill"></span><span class="mk__seg" id="seg4"></span><span class="mk__seg"></span>
</div>
<span class="mk__barlabel"><span id="nowpct">3</span>/5</span>
</td></tr>
<tr><td class="mk__list">
<div class="mk__step2 is-done">
<span class="mk__done" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><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 class="mk__label">이메일 주소 확인</span>
</div>
<div class="mk__step2 is-done">
<span class="mk__done" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><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 class="mk__label">프로필 사진 올리기</span>
</div>
<div class="mk__step2 is-done">
<span class="mk__done" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><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 class="mk__label">첫 소식 구독하기</span>
</div>
<button type="button" class="mk__step2 mk__next" id="next" aria-pressed="false">
<span class="mk__draw" aria-hidden="true"><svg viewBox="0 0 24 24" class="mk__drawsvg"><polyline class="mk__line" pathLength="1" points="4,13 9.5,18.5 20,6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="mk__label">알림 설정하기</span>
<span class="mk__hint">눌러서 완료</span>
</button>
<div class="mk__step2 is-todo">
<span class="mk__todo" aria-hidden="true"></span>
<span class="mk__label">친구 초대하기</span>
</div>
</td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-out;
$grass: #479074;
$color: $grass;
@include stage($stage-yellow);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.25; color: $subject-ink; padding-top: $sp-1 !important; }
.mk__body { font-weight: 500; font-size: clamp(9px, 2.8vw, 10px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__barrow { display: flex; align-items: center; gap: $sp-2; padding-top: 0 !important; padding-bottom: 0 !important; }
.mk__bar {
display: grid; grid-template-columns: repeat(5, 1fr); gap: $sp-1; flex: 1;
}
.mk__seg { height: 6px; border-radius: $r-pill; background: rgba(23,20,26,.1); }
.mk__seg.is-fill { background: $grass; }
.mk__barlabel { font-weight: 800; font-size: 10px; line-height: 1; color: rgba($subject-ink, .72); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mk__list { padding-top: $sp-1 !important; padding-bottom: 0 !important; }
.mk__step2 {
display: flex; align-items: center; gap: $sp-2; width: 100%; text-align: left;
background: none; border: none; cursor: default; font-family: inherit;
padding: $sp-1 0; border-top: 1px solid rgba(0,0,0,.06);
}
.mk__done, .mk__draw, .mk__todo {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
width: 18px; height: 18px; border-radius: 50%;
}
.mk__done { background: $grass; color: $subject-cream; }
.mk__ico { width: 12px; height: 12px; }
.mk__todo { background: rgba(23,20,26,.07); }
.mk__label { font-weight: 700; font-size: clamp(10px, 3vw, 12px); line-height: 1.2; color: rgba(23,20,26,.62); }
.is-done .mk__label { color: $subject-ink; }
.mk__next { cursor: pointer; background: rgba(76,212,166,.12); border-radius: $r-sm; padding-left: $sp-1; padding-right: $sp-1; }
.mk__next .mk__label { color: $subject-ink; }
.mk__draw { background: rgba(76,212,166,.3); color: $subject-ink; }
.mk__drawsvg { width: 12px; height: 12px; }
.mk__line { stroke-dasharray: 1; stroke-dashoffset: 1; }
.mk__next.is-done .mk__draw { background: $grass; }
.mk__next.is-done .mk__line { stroke-dashoffset: 0; }
.mk__next.is-done .mk__hint { visibility: hidden; }
.mk__hint { margin-left: auto; font-weight: 700; font-size: 8px; line-height: 1; color: $grass; background: $stage-paper; border-radius: $r-pill; padding: $sp-1 $sp-2; white-space: nowrap; }
.mk__next:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; border-radius: $r-sm; }
.mk__next:hover { background: rgba(76,212,166,.2); }
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 네 번째 줄 체크가 선을 따라 그려지고(0.6s), 힌트 알약이 맥박 (2s 주기, 복귀 86%)
.mk.is-demo .mk__line { animation-name: obDraw; animation-duration: 2s; animation-timing-function: $ease-out; animation-iteration-count: infinite; }
.mk.is-demo .mk__draw { animation-name: obFill; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__hint { animation-name: obHint; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
@keyframes obDraw {
0%, 8% { stroke-dashoffset: 1; }
38% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 0; }
86%, 100% { stroke-dashoffset: 1; }
}
@keyframes obFill {
0%, 8% { background: rgba(76,212,166,.3); }
38%, 84% { background: #479074; }
86%, 100% { background: rgba(76,212,166,.3); }
}
@keyframes obHint {
0%, 40% { transform: scale(1); }
50% { transform: scale(1.12); }
60%, 84% { transform: scale(1); }
86%, 100% { transform: scale(1); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__title { font-size: 11px !important; padding-top: 0 !important; }
.mk__body { font-size: 8px !important; padding-bottom: 0 !important; }
.mk__seg { height: 4px; }
.mk__label { font-size: 9px !important; }
.mk__step2 { padding: 0 !important; }
.mk__hint { font-size: 7px !important; padding: 0 $sp-1 !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__line,
.mk.is-demo .mk__draw,
.mk.is-demo .mk__hint { animation: none !important; }
.mk.is-demo .mk__line { stroke-dashoffset: 0; }
.mk.is-demo .mk__draw { background: $grass; }
}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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.25;
color: #17141a;
padding-top: 4px !important;
}
.mk__body {
font-weight: 500;
font-size: clamp(9px, 2.8vw, 10px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__barrow {
display: flex;
align-items: center;
gap: 8px;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mk__bar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
flex: 1;
}
.mk__seg {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
}
.mk__seg.is-fill {
background: #479074;
}
.mk__barlabel {
font-weight: 800;
font-size: 10px;
line-height: 1;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.mk__list {
padding-top: 4px !important;
padding-bottom: 0 !important;
}
.mk__step2 {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
background: none;
border: none;
cursor: default;
font-family: inherit;
padding: 4px 0;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.mk__done, .mk__draw, .mk__todo {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 18px;
height: 18px;
border-radius: 50%;
}
.mk__done {
background: #479074;
color: #fff7e6;
}
.mk__ico {
width: 12px;
height: 12px;
}
.mk__todo {
background: rgba(23, 20, 26, 0.07);
}
.mk__label {
font-weight: 700;
font-size: clamp(10px, 3vw, 12px);
line-height: 1.2;
color: rgba(23, 20, 26, 0.62);
}
.is-done .mk__label {
color: #17141a;
}
.mk__next {
cursor: pointer;
background: rgba(76, 212, 166, 0.12);
border-radius: 8px;
padding-left: 4px;
padding-right: 4px;
}
.mk__next .mk__label {
color: #17141a;
}
.mk__draw {
background: rgba(76, 212, 166, 0.3);
color: #17141a;
}
.mk__drawsvg {
width: 12px;
height: 12px;
}
.mk__line {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.mk__next.is-done .mk__draw {
background: #479074;
}
.mk__next.is-done .mk__line {
stroke-dashoffset: 0;
}
.mk__next.is-done .mk__hint {
visibility: hidden;
}
.mk__hint {
margin-left: auto;
font-weight: 700;
font-size: 8px;
line-height: 1;
color: #479074;
background: #ffffff;
border-radius: 999px;
padding: 4px 8px;
white-space: nowrap;
}
.mk__next:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 8px;
}
.mk__next:hover {
background: rgba(76, 212, 166, 0.2);
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .mk__line {
animation-name: obDraw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.mk.is-demo .mk__draw {
animation-name: obFill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__hint {
animation-name: obHint;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes obDraw {
0%, 8% {
stroke-dashoffset: 1;
}
38% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 0;
}
86%, 100% {
stroke-dashoffset: 1;
}
}
@keyframes obFill {
0%, 8% {
background: rgba(76, 212, 166, 0.3);
}
38%, 84% {
background: #479074;
}
86%, 100% {
background: rgba(76, 212, 166, 0.3);
}
}
@keyframes obHint {
0%, 40% {
transform: scale(1);
}
50% {
transform: scale(1.12);
}
60%, 84% {
transform: scale(1);
}
86%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__title {
font-size: 11px !important;
padding-top: 0 !important;
}
.mk__body {
font-size: 8px !important;
padding-bottom: 0 !important;
}
.mk__seg {
height: 4px;
}
.mk__label {
font-size: 9px !important;
}
.mk__step2 {
padding: 0 !important;
}
.mk__hint {
font-size: 7px !important;
padding: 0 4px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__line,
.mk.is-demo .mk__draw,
.mk.is-demo .mk__hint {
animation: none !important;
}
.mk.is-demo .mk__line {
stroke-dashoffset: 0;
}
.mk.is-demo .mk__draw {
background: #479074;
}
}var mk = document.getElementById('mk');
var next = document.getElementById('next');
var seg4 = document.getElementById('seg4');
var bar = document.getElementById('bar');
var nowpct = document.getElementById('nowpct');
next.addEventListener('click', function () {
mk.classList.remove('is-demo');
var done = next.classList.toggle('is-done');
next.setAttribute('aria-pressed', String(done));
seg4.classList.toggle('is-fill', done);
bar.setAttribute('aria-valuenow', done ? '80' : '60');
nowpct.textContent = done ? '4' : '3';
});import { useState } from "react";
import styles from "./OnboardingChecklist.module.scss";
export type OnboardingChecklistProps = { duration?: number; easing?: string; color?: string };
const DONE = ["이메일 주소 확인", "프로필 사진 올리기", "첫 소식 구독하기"];
const NEXT = "알림 설정하기";
const TODO = "친구 초대하기";
export default function OnboardingChecklist({ duration = 600, easing = "ease-out", color = "#0f7a52" }: OnboardingChecklistProps) {
const [demo, setDemo] = useState(true);
const [done4, setDone4] = useState(false);
const steps = 3 + (done4 ? 1 : 0);
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.title}>반가워요! 시작할 준비가 거의 끝났어요</div>
<div className={styles.body}>5단계 중 3단계를 끝냈어요. 아래 칸을 눌러 다음 단계를 끝내 보세요.</div>
<div className={styles.barrow}>
<div className={styles.bar} role="progressbar" aria-valuenow={steps * 20} aria-valuemin={0} aria-valuemax={100} aria-label="시작 단계 진행률">
{[0, 1, 2, 3, 4].map((i) => (
<span key={i} className={`${styles.seg} ${(i < 3 || (i === 3 && done4)) ? styles.isFill : ""}`}></span>
))}
</div>
<span className={styles.barlabel}>{steps}/5</span>
</div>
<div className={styles.list}>
{DONE.map((label) => (
<div key={label} className={`${styles.stepRow} ${styles.isDone}`}>
<span className={styles.done} style={{ background: "#0f7a52" }} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><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 className={styles.label}>{label}</span>
</div>
))}
<button
type="button"
className={`${styles.stepRow} ${styles.next} ${done4 ? styles.isDone : ""}`}
aria-pressed={done4}
onClick={() => { setDemo(false); setDone4((v) => !v); }}
>
<span className={styles.draw} style={{ ...(demo ? { animationName: "obFill", animationDuration: "2s", animationTimingFunction: "steps(1, end)", animationIterationCount: "infinite", background: color } : (done4 ? { background: "#0f7a52" } : {})) }} aria-hidden="true">
<svg viewBox="0 0 24 24" className={styles.drawsvg}><polyline className={styles.line} pathLength="1" points="4,13 9.5,18.5 20,6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style={demo ? { animationName: "obDraw", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite" } : undefined} /></svg>
</span>
<span className={styles.label}>{NEXT}</span>
<span className={styles.hint} style={demo ? { animationName: "obHint", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite" } : undefined}>눌러서 완료</span>
</button>
<div className={`${styles.stepRow} ${styles.isTodo}`}>
<span className={styles.todo} aria-hidden="true"></span>
<span className={styles.label}>{TODO}</span>
</div>
</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}06 · 온보딩 체크리스트 — 측정 누적면적 0.528% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
가입 직후 연속 메일과 기능 소개 시리즈에 씁니다.
.mk__line { stroke-dasharray: 1; stroke-dashoffset: 1; }
.mk__next.is-done .mk__line { stroke-dashoffset: 0; }
// 미리보기 루프: 체크가 8%부터 38%까지 선을 따라 그려진다
.mk.is-demo .mk__line {
animation-name: obDraw;
animation-duration: 2s;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes obDraw {
0%, 8% { stroke-dashoffset: 1; }
38% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 0; }
86%, 100% { stroke-dashoffset: 1; }
}
07별점 설문 메일
무엇이 보이나
별 다섯 개가 왼쪽부터 0.16초 간격으로 차오르다 네 번째에서 멈추고, 감사 문구 한 줄이 올라와 답이 메일 안에서 끝납니다. 별은 role="radiogroup" 안의 라디오라 화면 낭독기에도 선택으로 읽히고, 누른 별까지 색이 채워지며 문구가 점수로 바뀝니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td align="center" class="mk__title">지난 주 쇼핑은 어땠나요?</td></tr>
<tr><td align="center" class="mk__body">별을 누르면 답변이 기록돼요. 10초면 끝나요.</td></tr>
<tr><td align="center" class="mk__starsrow">
<div class="mk__stars" role="radiogroup" aria-label="별점 5점 만점" id="stars">
<button type="button" class="mk__star" role="radio" aria-checked="false" aria-label="1점"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2"/><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></svg></button>
<button type="button" class="mk__star" role="radio" aria-checked="false" aria-label="2점"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2"/><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></svg></button>
<button type="button" class="mk__star" role="radio" aria-checked="false" aria-label="3점"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2"/><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></svg></button>
<button type="button" class="mk__star" role="radio" aria-checked="false" aria-label="4점"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2"/><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></svg></button>
<button type="button" class="mk__star" role="radio" aria-checked="false" aria-label="5점"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2"/><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></svg></button>
</div>
</td></tr>
<tr><td align="center" class="mk__thanks"><span class="mk__thankline" id="thankline">감사해요! 다른 이야기가 있으면 답장으로 들려주세요.</span></td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-orange);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.25; color: $subject-ink; padding-top: $sp-2 !important; }
.mk__body { font-weight: 500; font-size: clamp(9px, 2.8vw, 10px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__starsrow { padding-top: 0 !important; padding-bottom: 0 !important; }
.mk__stars { display: inline-flex; gap: $sp-1; }
.mk__star {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(28px, 9vw, 38px); height: clamp(28px, 9vw, 38px);
background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
color: rgba(23,20,26,.22);
}
.mk__ico { width: 100%; height: 100%; }
.mk__star.is-fill { color: $stage-orange; }
.mk__star:hover { color: rgba(23,20,26,.4); }
.mk__star.is-fill:hover { color: $stage-orange; }
.mk__star:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; border-radius: $r-xs; }
.mk__thanks { padding-top: $sp-1 !important; padding-bottom: 0 !important; min-height: 28px; }
.mk__thankline { font-weight: 600; font-size: clamp(9px, 2.8vw, 10px); line-height: 1.4; color: rgba(23,20,26,.62); }
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 별이 왼쪽부터 차례로 채워지다 네 번째에서 멈추고, 감사 문구가 올라온다 (2s 주기, 복귀 86%)
.mk.is-demo .mk__star:nth-child(1) { animation-name: svFill; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 120ms; }
.mk.is-demo .mk__star:nth-child(2) { animation-name: svFill; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 280ms; }
.mk.is-demo .mk__star:nth-child(3) { animation-name: svFill; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 440ms; }
.mk.is-demo .mk__star:nth-child(4) { animation-name: svFill; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 600ms; }
.mk.is-demo .mk__star:nth-child(5) { animation-name: svPop; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
.mk.is-demo .mk__thankline { animation-name: svThanks; animation-duration: 2s; animation-timing-function: $ease-out; animation-iteration-count: infinite; }
@keyframes svFill {
0% { color: rgba(23,20,26,.22); }
30%, 84% { color: $stage-orange; }
86%, 100% { color: rgba(23,20,26,.22); }
}
@keyframes svPop {
0%, 56% { transform: scale(1); }
62% { transform: scale(1.14); }
70%, 100% { transform: scale(1); }
}
@keyframes svThanks {
0%, 58% { opacity: 0; transform: translateY(6px); }
72% { opacity: 1; transform: translateY(0); }
84% { opacity: 1; transform: translateY(0); }
90%, 100% { opacity: 0; transform: translateY(6px); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__title { font-size: 11px !important; padding-top: $sp-1 !important; }
.mk__body { font-size: 8px !important; padding-bottom: 0 !important; }
.mk__star { width: 24px; height: 24px; }
.mk__thanks { min-height: 0; padding-top: 0 !important; }
.mk__thankline { font-size: 8px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__star,
.mk.is-demo .mk__thankline { animation: none !important; }
}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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.25;
color: #17141a;
padding-top: 8px !important;
}
.mk__body {
font-weight: 500;
font-size: clamp(9px, 2.8vw, 10px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__starsrow {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mk__stars {
display: inline-flex;
gap: 4px;
}
.mk__star {
display: inline-flex;
align-items: center;
justify-content: center;
width: clamp(28px, 9vw, 38px);
height: clamp(28px, 9vw, 38px);
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: inherit;
color: rgba(23, 20, 26, 0.22);
}
.mk__ico {
width: 100%;
height: 100%;
}
.mk__star.is-fill {
color: #ff4d1f;
}
.mk__star:hover {
color: rgba(23, 20, 26, 0.4);
}
.mk__star.is-fill:hover {
color: #ff4d1f;
}
.mk__star:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 4px;
}
.mk__thanks {
padding-top: 4px !important;
padding-bottom: 0 !important;
min-height: 28px;
}
.mk__thankline {
font-weight: 600;
font-size: clamp(9px, 2.8vw, 10px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .mk__star:nth-child(1) {
animation-name: svFill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: 120ms;
}
.mk.is-demo .mk__star:nth-child(2) {
animation-name: svFill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: 280ms;
}
.mk.is-demo .mk__star:nth-child(3) {
animation-name: svFill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: 440ms;
}
.mk.is-demo .mk__star:nth-child(4) {
animation-name: svFill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: 600ms;
}
.mk.is-demo .mk__star:nth-child(5) {
animation-name: svPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__thankline {
animation-name: svThanks;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes svFill {
0% {
color: rgba(23, 20, 26, 0.22);
}
30%, 84% {
color: #ff4d1f;
}
86%, 100% {
color: rgba(23, 20, 26, 0.22);
}
}
@keyframes svPop {
0%, 56% {
transform: scale(1);
}
62% {
transform: scale(1.14);
}
70%, 100% {
transform: scale(1);
}
}
@keyframes svThanks {
0%, 58% {
opacity: 0;
transform: translateY(6px);
}
72% {
opacity: 1;
transform: translateY(0);
}
84% {
opacity: 1;
transform: translateY(0);
}
90%, 100% {
opacity: 0;
transform: translateY(6px);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__title {
font-size: 11px !important;
padding-top: 4px !important;
}
.mk__body {
font-size: 8px !important;
padding-bottom: 0 !important;
}
.mk__star {
width: 24px;
height: 24px;
}
.mk__thanks {
min-height: 0;
padding-top: 0 !important;
}
.mk__thankline {
font-size: 8px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__star,
.mk.is-demo .mk__thankline {
animation: none !important;
}
}var mk = document.getElementById('mk');
var stars = document.querySelectorAll('.mk__star');
var thankline = document.getElementById('thankline');
for (var i = 0; i < stars.length; i++) {
stars[i].addEventListener('click', function () {
mk.classList.remove('is-demo');
var idx = Array.prototype.indexOf.call(stars, this);
for (var j = 0; j < stars.length; j++) {
stars[j].classList.toggle('is-fill', j <= idx);
stars[j].setAttribute('aria-checked', String(j === idx));
}
thankline.textContent = (idx + 1) + '점을 주셨네요, 감사합니다.';
});
}import { useState } from "react";
import styles from "./StarSurvey.module.scss";
export type StarSurveyProps = { duration?: number; easing?: string; color?: string };
function StarIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}>
<path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2" />
<path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z" />
</svg>
);
}
export default function StarSurvey({ duration = 220, easing = "ease-out", color = "#ff4d1f" }: StarSurveyProps) {
const [demo, setDemo] = useState(true);
const [rating, setRating] = useState(0);
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.title}>지난 주 쇼핑은 어땠나요?</div>
<div className={styles.body}>별을 누르면 답변이 기록돼요. 10초면 끝나요.</div>
<div className={styles.starsrow}>
<div className={styles.stars} role="radiogroup" aria-label="별점 5점 만점">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`${styles.star} ${n <= rating ? styles.isFill : ""}`}
style={{
color: n <= rating ? color : undefined,
...(demo ? { animationName: n < 5 ? "svFill" : "svPop", animationDuration: "2s", animationTimingFunction: n < 5 ? "steps(1, end)" : easing, animationIterationCount: "infinite", animationDelay: `${[120, 280, 440, 600, 0][n - 1]}ms` } : {}),
}}
role="radio"
aria-checked={rating === n}
aria-label={`${n}점`}
onClick={() => { setDemo(false); setRating((r) => (r === n ? 0 : n)); }}
>
<StarIcon />
</button>
))}
</div>
</div>
<div className={styles.thanks}>
<span className={styles.thankline} style={demo ? { animationName: "svThanks", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite" } : undefined}>
{rating > 0 ? `${rating}점을 주셨네요, 감사합니다.` : "감사해요! 다른 이야기가 있으면 답장으로 들려주세요."}
</span>
</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}07 · 별점 설문 메일 — 측정 누적면적 2.924% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
구매 후 만족도 조사와 NPS 수집 메일에 씁니다.
.mk.is-demo .mk__star:nth-child(1) { animation-name: svFill; animation-duration: 2s;
animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 120ms; }
.mk.is-demo .mk__star:nth-child(2) { animation-name: svFill; animation-duration: 2s;
animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 280ms; }
.mk.is-demo .mk__star:nth-child(3) { animation-name: svFill; animation-duration: 2s;
animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 440ms; }
.mk.is-demo .mk__star:nth-child(4) { animation-name: svFill; animation-duration: 2s;
animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 600ms; }
@keyframes svFill {
0% { color: rgba(23,20,26,.22); }
30%, 84% { color: $stage-orange; }
86%, 100% { color: rgba(23,20,26,.22); }
}
08휴면 고객 재방문 메일
화면에서 일어나는 일
그동안 쌓인 숫자 세 칸이 requestAnimationFrame 으로 0에서 실제 값까지 굴러 올라가고(1.1초), 아래 버튼이 떠오릅니다. 움직인 프레임은 23/23 로 캡처 내내 숫자가 변하며, 시스템이 어두운 모드면 prefers-color-scheme 블록이 카드를 자동으로 뒤집고 숫자 색도 하늘색으로 밝힙니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td align="center" class="mk__hero">
<span class="mk__badge" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M224,64V208H32V48H208A16,16,0,0,1,224,64Z" opacity="0.2"/><path d="M232,208a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V48a8,8,0,0,1,16,0V156.69l50.34-50.35a8,8,0,0,1,11.32,0L128,132.69,180.69,80H160a8,8,0,0,1,0-16h40a8,8,0,0,1,8,8v40a8,8,0,0,1-16,0V91.31l-58.34,58.35a8,8,0,0,1-11.32,0L96,123.31l-56,56V200H224A8,8,0,0,1,232,208Z"/></svg></span>
</td></tr>
<tr><td align="center" class="mk__title">돌아오셨네요! 그동안 있었던 일</td></tr>
<tr><td class="mk__stats">
<div class="mk__stat">
<span class="mk__num" data-target="1280">0</span>
<span class="mk__statlabel">새로 가입한 이웃</span>
</div>
<div class="mk__stat">
<span class="mk__num" data-target="36">0</span>
<span class="mk__statlabel">새로 올라온 글</span>
</div>
<div class="mk__stat">
<span class="mk__num" data-target="14">0</span>
<span class="mk__statlabel">떠나 있던 날</span>
</div>
</td></tr>
<tr><td align="center" class="mk__ctawrap"><a href="#" role="button" class="mk__cta">다시 시작하기</a></td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-paper);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; border: 1px solid rgba(23,20,26,.1); overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__hero { padding-top: $sp-1 !important; padding-bottom: 0 !important; }
.mk__badge {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(26px, 8vw, 34px); height: clamp(26px, 8vw, 34px); border-radius: $r-sm;
background: #0a4fe8; color: $subject-cream;
}
.mk__ico { width: 62%; height: 62%; }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.25; color: $subject-ink; padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__stats {
display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2;
padding-top: 0 !important; padding-bottom: $sp-1 !important;
}
.mk__stat {
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
background: rgba(47,109,246,.06); border-radius: $r-sm; padding: $sp-2 $sp-1;
}
.mk__num { font-weight: 800; font-size: clamp(14px, 4.6vw, 19px); line-height: 1; color: $subject-blue; font-variant-numeric: tabular-nums; }
.mk__statlabel { font-weight: 600; font-size: 8px; line-height: 1.3; color: rgba(23,20,26,.62); text-align: center; }
.mk__ctawrap { padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__cta {
display: inline-block; padding: $sp-1 $sp-6; font-weight: 700; font-size: 11px; line-height: 1.6;
font-family: inherit; color: $subject-cream; text-decoration: none; border-radius: $r-control;
background: #7c90be; // CTA 파랑을 어둡게 — 크림 글자 4.25:1 미달이라 5.29:1 로 (279 선례)
}
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 다크 모드: 시스템 설정을 따라가는 판 (prefers-color-scheme) — 메일 앱 다크 테마 흉내
@media (prefers-color-scheme: dark) {
.mk__card { background: $stage-ink; border-color: rgba(255,247,230,.14); }
.mk__brand { color: rgba(255,247,230,.7); }
.mk__title { color: $subject-cream; }
.mk__stat { background: rgba(255,247,230,.08); }
.mk__num { color: $subject-sky; }
.mk__statlabel { color: rgba(255,247,230,.55); }
.mk__footer { color: rgba(255,247,230,.4); }
}
// ── 미리보기 루프: 통계 칸이 살짝 떠오르고 CTA 가 숨쉰다 (2s 주기, 복귀 86%)
// 숫자 굴러 오르기(0→값)는 requestAnimationFrame 스크립트가 담당 — CSS 시계 밖 실시간
.mk.is-demo .mk__stat { animation-name: rgRise; animation-duration: 2s; animation-timing-function: $easing; animation-iteration-count: infinite; }
.mk.is-demo .mk__stat:nth-child(2) { animation-delay: 100ms; }
.mk.is-demo .mk__stat:nth-child(3) { animation-delay: 200ms; }
.mk.is-demo .mk__cta { animation-name: rgBreath; animation-duration: 2s; animation-timing-function: $ease-out; animation-iteration-count: infinite; }
@keyframes rgRise {
0% { opacity: 0; transform: translateY(10px); }
26% { opacity: 1; transform: translateY(0); }
82% { opacity: 1; transform: translateY(0); }
86% { opacity: 0; transform: translateY(6px); }
100% { opacity: 0; transform: translateY(10px); }
}
@keyframes rgBreath {
0%, 12% { opacity: .3; transform: translateY(8px); }
44% { opacity: 1; transform: translateY(0); }
60% { transform: translateY(0) scale(1.03); }
76% { opacity: 1; transform: translateY(0) scale(1); }
84%, 100% { opacity: .3; transform: translateY(8px) scale(1); }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__badge { width: 22px; height: 22px; }
.mk__title { font-size: 11px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__num { font-size: 13px !important; }
.mk__statlabel { font-size: 7px !important; }
.mk__stat { padding: $sp-1 0 !important; }
.mk__cta { padding: 0 $sp-5 !important; font-size: 10px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__stat,
.mk.is-demo .mk__cta { animation: none !important; opacity: 1; transform: 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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 1px solid rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__hero {
padding-top: 4px !important;
padding-bottom: 0 !important;
}
.mk__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: clamp(26px, 8vw, 34px);
height: clamp(26px, 8vw, 34px);
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
}
.mk__ico {
width: 62%;
height: 62%;
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.25;
color: #17141a;
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: rgba(47, 109, 246, 0.06);
border-radius: 8px;
padding: 8px 4px;
}
.mk__num {
font-weight: 800;
font-size: clamp(14px, 4.6vw, 19px);
line-height: 1;
color: #2f6df6;
font-variant-numeric: tabular-nums;
}
.mk__statlabel {
font-weight: 600;
font-size: 8px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.62);
text-align: center;
}
.mk__ctawrap {
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__cta {
display: inline-block;
padding: 4px 24px;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #fff7e6;
text-decoration: none;
border-radius: 12px;
background: #7c90be;
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
@media (prefers-color-scheme: dark) {
.mk__card {
background: #17141a;
border-color: rgba(255, 247, 230, 0.14);
}
.mk__brand {
color: rgba(255, 247, 230, 0.7);
}
.mk__title {
color: #fff7e6;
}
.mk__stat {
background: rgba(255, 247, 230, 0.08);
}
.mk__num {
color: #6ec8ff;
}
.mk__statlabel {
color: rgba(255, 247, 230, 0.55);
}
.mk__footer {
color: rgba(255, 247, 230, 0.4);
}
}
.mk.is-demo .mk__stat {
animation-name: rgRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mk.is-demo .mk__stat:nth-child(2) {
animation-delay: 100ms;
}
.mk.is-demo .mk__stat:nth-child(3) {
animation-delay: 200ms;
}
.mk.is-demo .mk__cta {
animation-name: rgBreath;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes rgRise {
0% {
opacity: 0;
transform: translateY(10px);
}
26% {
opacity: 1;
transform: translateY(0);
}
82% {
opacity: 1;
transform: translateY(0);
}
86% {
opacity: 0;
transform: translateY(6px);
}
100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes rgBreath {
0%, 12% {
opacity: 0.3;
transform: translateY(8px);
}
44% {
opacity: 1;
transform: translateY(0);
}
60% {
transform: translateY(0) scale(1.03);
}
76% {
opacity: 1;
transform: translateY(0) scale(1);
}
84%, 100% {
opacity: 0.3;
transform: translateY(8px) scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__badge {
width: 22px;
height: 22px;
}
.mk__title {
font-size: 11px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__num {
font-size: 13px !important;
}
.mk__statlabel {
font-size: 7px !important;
}
.mk__stat {
padding: 4px 0 !important;
}
.mk__cta {
padding: 0 20px !important;
font-size: 10px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .mk__stat,
.mk.is-demo .mk__cta {
animation: none !important;
opacity: 1;
transform: none;
}
}var counters = document.querySelectorAll('.mk__num');
var DURATION = 1100;
for (var c = 0; c < counters.length; c++) {
counters[c].setAttribute('data-start', String(performance.now()));
}
function rollNumber(now) {
var allDone = true;
for (var i = 0; i < counters.length; i++) {
var el = counters[i];
var target = parseInt(el.getAttribute('data-target'), 10);
var started = parseFloat(el.getAttribute('data-start'));
var t = Math.min(1, (now - started) / DURATION);
var eased = 1 - Math.pow(1 - t, 3);
el.textContent = Math.round(target * eased).toLocaleString('ko-KR');
if (t < 1) { allDone = false; }
}
if (!allDone) { requestAnimationFrame(rollNumber); }
}
requestAnimationFrame(rollNumber);import { useEffect, useRef, useState } from "react";
import styles from "./WinbackStats.module.scss";
export type WinbackStatsProps = { duration?: number; easing?: string; color?: string };
const STATS = [
{ target: 1280, label: "새로 가입한 이웃" },
{ target: 36, label: "새로 올라온 글" },
{ target: 14, label: "떠나 있던 날" },
];
export default function WinbackStats({ duration = 600, easing = "cubic-bezier(.2,.8,.2,1)", color = "#0a4fe8" }: WinbackStatsProps) {
const [demo, setDemo] = useState(true);
const [vals, setVals] = useState<number[]>([0, 0, 0]);
const startRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const rollMs = 1100;
useEffect(() => {
const roll = (now: number) => {
if (startRef.current === null) { startRef.current = now; }
const t = Math.min(1, (now - startRef.current) / rollMs);
const eased = 1 - Math.pow(1 - t, 3);
setVals(STATS.map((s) => Math.round(s.target * eased)));
if (t < 1) { rafRef.current = requestAnimationFrame(roll); }
};
rafRef.current = requestAnimationFrame(roll);
return () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); } };
}, []);
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.hero}>
<span className={styles.badge} style={{ background: color }} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M224,64V208H32V48H208A16,16,0,0,1,224,64Z" opacity="0.2"/><path d="M232,208a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V48a8,8,0,0,1,16,0V156.69l50.34-50.35a8,8,0,0,1,11.32,0L128,132.69,180.69,80H160a8,8,0,0,1,0-16h40a8,8,0,0,1,8,8v40a8,8,0,0,1-16,0V91.31l-58.34,58.35a8,8,0,0,1-11.32,0L96,123.31l-56,56V200H224A8,8,0,0,1,232,208Z"/></svg>
</span>
</div>
<div className={styles.title}>돌아오셨네요! 그동안 있었던 일</div>
<div className={styles.stats}>
{STATS.map((s, i) => (
<div key={s.label} className={styles.stat} style={demo ? { animationName: "rgRise", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite", animationDelay: `${i * 100}ms` } : undefined}>
<span className={styles.num} style={{ color: color }}>{vals[i].toLocaleString("ko-KR")}</span>
<span className={styles.statlabel}>{s.label}</span>
</div>
))}
</div>
<div className={styles.ctawrap}>
<a href="#" role="button" className={styles.cta} style={{ background: "#2a5fd8", ...(demo ? { animationName: "rgBreath", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite" } : {}) }} onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); setDemo(false); }}>다시 시작하기</a>
</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}08 · 휴면 고객 재방문 메일 — 측정 누적면적 4.815% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
3개월 미방문 고객 재활성화와 구독 갱신 안내에 씁니다.
var counters = document.querySelectorAll('.mk__num');
var DURATION = 1100;
for (var c = 0; c < counters.length; c++) {
counters[c].setAttribute('data-start', String(performance.now()));
}
function rollNumber(now) {
var allDone = true;
for (var i = 0; i < counters.length; i++) {
var el = counters[i];
var target = parseInt(el.getAttribute('data-target'), 10);
var started = parseFloat(el.getAttribute('data-start'));
var t = Math.min(1, (now - started) / DURATION);
var eased = 1 - Math.pow(1 - t, 3);
el.textContent = Math.round(target * eased).toLocaleString('ko-KR');
if (t < 1) { allDone = false; }
}
if (!allDone) { requestAnimationFrame(rollNumber); }
}
requestAnimationFrame(rollNumber);
09친구 추천 코드 메일
어떤 움직임인가
추천 코드 알약 아래 점 세 개가 차례로 차올라 두 개까지 채워지고, 세 번째가 비어 있어 한 명만 더 초대하면 된다는 것을 보여줍니다. 지연이 걸린 점은 animation-fill-mode: backwards 로 첫 장 번쩍임을 막았고, 코드는 옆의 복사 버튼을 눌러 복사되며 결과가 aria-live 로 알립니다.

<main class="stage">
<div class="mk is-demo" id="mk">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__table">
<tr><td align="center" class="mk__wrap">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="mk__card" style="max-width:400px">
<tr><td class="mk__brand">◆ Acme</td></tr>
<tr><td align="center" class="mk__hero">
<span class="mk__badge" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="mk__ico"><path d="M168,144a40,40,0,1,1-40-40A40,40,0,0,1,168,144ZM64,56A32,32,0,1,0,96,88,32,32,0,0,0,64,56Zm128,0a32,32,0,1,0,32,32A32,32,0,0,0,192,56Z" opacity="0.2"/><path d="M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1,0-16,24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.85,8,57,57,0,0,0-98.15,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z"/></svg></span>
</td></tr>
<tr><td align="center" class="mk__title">친구를 초대하면 둘 다 10,000원</td></tr>
<tr><td align="center" class="mk__body">아래 코드로 친구가 가입하면, 두 분 모두 쿠폰을 드려요.</td></tr>
<tr><td class="mk__coderow">
<span class="mk__code" id="code">GODRICH-9K2F</span>
<button type="button" class="mk__copy" id="copybtn" aria-label="추천 코드 복사">복사</button>
</td></tr>
<tr><td class="mk__dotsrow">
<div class="mk__dots" role="img" aria-label="초대 3칸 중 2칸 완료">
<span class="mk__dot mk__dotfill d1"></span>
<span class="mk__dot mk__dotfill d2"></span>
<span class="mk__dot"></span>
</div>
<span class="mk__dotslabel">2 / 3 초대 완료 — 한 명 더!</span>
</td></tr>
<tr><td align="center" class="mk__ctawrap"><a href="#" role="button" class="mk__cta">친구 초대하기</a></td></tr>
<tr><td align="center" class="mk__live" id="live" aria-live="polite"></td></tr>
<tr><td align="center" class="mk__footer">Acme Inc · 서울 · <a href="#" class="mk__unsub">수신거부</a></td></tr>
</table>
</td></tr>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-lilac;
@include stage($stage-ink);
.stage { position: relative; }
.mk { width: min(100%, 400px); }
.mk__table { border-collapse: collapse; }
.mk__wrap { padding: 0; }
.mk__card {
border-collapse: collapse; background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.mk__card td { padding: $sp-1 $sp-5; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; -webkit-text-size-adjust: 100%; line-height: 1; }
.mk__brand { font-weight: 800; font-size: 11px; line-height: 1; color: rgba(23,20,26,.62); letter-spacing: .02em; padding-top: $sp-2 !important; }
.mk__hero { padding-top: $sp-1 !important; padding-bottom: 0 !important; }
.mk__badge {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(26px, 8vw, 34px); height: clamp(26px, 8vw, 34px); border-radius: 50%;
background: $subject-lilac; color: $subject-ink;
}
.mk__ico { width: 60%; height: 60%; }
.mk__title { font-weight: 800; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.25; color: $subject-ink; padding-top: $sp-1 !important; }
.mk__body { font-weight: 500; font-size: clamp(10px, 3vw, 11px); line-height: 1.4; color: rgba(23,20,26,.62); padding-top: 0 !important; padding-bottom: $sp-1 !important; }
.mk__coderow { padding-top: 0 !important; padding-bottom: 0 !important; }
.mk__code {
display: inline-block; font-weight: 800; font-size: clamp(12px, 3.8vw, 15px); line-height: 1.2;
letter-spacing: .12em; color: #5b3fc4; background: rgba(185,160,255,.14);
border-radius: $r-pill; padding: $sp-1 $sp-4; font-variant-numeric: tabular-nums;
}
.mk__copy {
display: inline-block; margin-left: $sp-2; font-family: inherit; font-weight: 700; font-size: 10px; line-height: 1.6;
color: $subject-ink; background: $subject-lilac; border: none; border-radius: $r-pill;
padding: $sp-1 $sp-3; cursor: pointer;
}
.mk__copy:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.mk__dotsrow { padding-top: $sp-2 !important; padding-bottom: 0 !important; }
.mk__dots { display: inline-flex; align-items: center; gap: $sp-1; }
.mk__dot {
width: 10px; height: 10px; border-radius: 50%; background: none;
border: 2px solid rgba(23,20,26,.2); box-sizing: border-box;
}
.mk__dotfill { border-color: #5b3fc4; background: #5b3fc4; }
.mk__dotslabel { margin-left: $sp-2; font-weight: 600; font-size: 9px; line-height: 1.3; color: rgba(23,20,26,.62); }
.mk__ctawrap { padding-top: $sp-2 !important; padding-bottom: $sp-1 !important; }
.mk__cta {
display: inline-block; padding: $sp-1 $sp-6; font-weight: 700; font-size: 11px; line-height: 1.6;
font-family: inherit; color: $subject-cream; text-decoration: none; border-radius: $r-control;
background: $subject-ink;
}
.mk__live { font-weight: 600; font-size: 9px; line-height: 1.4; color: rgba(23,20,26,.62); padding: 0 !important; min-height: 12px; }
.mk__footer { font-weight: 500; font-size: 8px; line-height: 1.4; color: rgba(23,20,26,.62); padding-top: $sp-1 !important; padding-bottom: $sp-2 !important; }
.mk__footer a { color: inherit; }
// ── 미리보기 루프: 점 두 개가 차례로 차오르고(셋째는 비어 있음), 코드 알약이 살짝 숨쉰다 (2s 주기, 복귀 88%)
.mk.is-demo .d1 { animation-name: rfDot; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; animation-delay: 160ms; animation-fill-mode: backwards; }
.mk.is-demo .d2 { animation-name: rfDot; animation-duration: 2s; animation-timing-function: $ease-pop; animation-iteration-count: infinite; animation-delay: 380ms; animation-fill-mode: backwards; }
.mk.is-demo .mk__code { animation-name: rfPill; animation-duration: 2s; animation-timing-function: $ease-out; animation-iteration-count: infinite; }
@keyframes rfDot {
0% { transform: scale(0); background: transparent; border-color: rgba(23,20,26,.2); }
18%, 84% { transform: scale(1); background: #5b3fc4; border-color: #5b3fc4; }
88%, 100% { transform: scale(0); background: transparent; border-color: rgba(23,20,26,.2); }
}
@keyframes rfPill {
0%, 12% { transform: translateY(4px); opacity: 0; }
30%, 84% { transform: translateY(0); opacity: 1; }
92%, 100% { transform: translateY(4px); opacity: 0; }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.mk__card td { padding-left: $sp-3; padding-right: $sp-3; padding-top: 0; padding-bottom: 0; }
.mk__brand { padding-top: 0 !important; font-size: 9px !important; }
.mk__badge { width: 22px; height: 22px; }
.mk__title { font-size: 11px !important; padding-top: 0 !important; }
.mk__body { font-size: 8px !important; padding-bottom: 0 !important; }
.mk__code { font-size: 10px !important; padding: 0 $sp-3 !important; }
.mk__copy { font-size: 9px !important; padding: 0 $sp-2 !important; margin-left: $sp-1 !important; }
.mk__dot { width: 8px; height: 8px; }
.mk__dotslabel { font-size: 8px !important; }
.mk__dotsrow { padding-top: $sp-1 !important; }
.mk__ctawrap { padding-top: $sp-1 !important; }
.mk__cta { padding: 0 $sp-5 !important; font-size: 10px !important; }
.mk__live { min-height: 0; font-size: 8px !important; }
.mk__footer { font-size: 7px !important; padding-top: 0 !important; padding-bottom: $sp-1 !important; }
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .d1,
.mk.is-demo .d2,
.mk.is-demo .mk__code { animation: none !important; transform: none; opacity: 1; }
}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);
}
.stage {
position: relative;
}
.mk {
width: min(100%, 400px);
}
.mk__table {
border-collapse: collapse;
}
.mk__wrap {
padding: 0;
}
.mk__card {
border-collapse: collapse;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mk__card td {
padding: 4px 20px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
-webkit-text-size-adjust: 100%;
line-height: 1;
}
.mk__brand {
font-weight: 800;
font-size: 11px;
line-height: 1;
color: rgba(23, 20, 26, 0.62);
letter-spacing: 0.02em;
padding-top: 8px !important;
}
.mk__hero {
padding-top: 4px !important;
padding-bottom: 0 !important;
}
.mk__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: clamp(26px, 8vw, 34px);
height: clamp(26px, 8vw, 34px);
border-radius: 50%;
background: #b9a0ff;
color: #17141a;
}
.mk__ico {
width: 60%;
height: 60%;
}
.mk__title {
font-weight: 800;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.25;
color: #17141a;
padding-top: 4px !important;
}
.mk__body {
font-weight: 500;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 0 !important;
padding-bottom: 4px !important;
}
.mk__coderow {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mk__code {
display: inline-block;
font-weight: 800;
font-size: clamp(12px, 3.8vw, 15px);
line-height: 1.2;
letter-spacing: 0.12em;
color: #5b3fc4;
background: rgba(185, 160, 255, 0.14);
border-radius: 999px;
padding: 4px 16px;
font-variant-numeric: tabular-nums;
}
.mk__copy {
display: inline-block;
margin-left: 8px;
font-family: inherit;
font-weight: 700;
font-size: 10px;
line-height: 1.6;
color: #17141a;
background: #b9a0ff;
border: none;
border-radius: 999px;
padding: 4px 12px;
cursor: pointer;
}
.mk__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mk__dotsrow {
padding-top: 8px !important;
padding-bottom: 0 !important;
}
.mk__dots {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mk__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: none;
border: 2px solid rgba(23, 20, 26, 0.2);
box-sizing: border-box;
}
.mk__dotfill {
border-color: #5b3fc4;
background: #5b3fc4;
}
.mk__dotslabel {
margin-left: 8px;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.62);
}
.mk__ctawrap {
padding-top: 8px !important;
padding-bottom: 4px !important;
}
.mk__cta {
display: inline-block;
padding: 4px 24px;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #fff7e6;
text-decoration: none;
border-radius: 12px;
background: #17141a;
}
.mk__live {
font-weight: 600;
font-size: 9px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding: 0 !important;
min-height: 12px;
}
.mk__footer {
font-weight: 500;
font-size: 8px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
padding-top: 4px !important;
padding-bottom: 8px !important;
}
.mk__footer a {
color: inherit;
}
.mk.is-demo .d1 {
animation-name: rfDot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: 160ms;
animation-fill-mode: backwards;
}
.mk.is-demo .d2 {
animation-name: rfDot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: 380ms;
animation-fill-mode: backwards;
}
.mk.is-demo .mk__code {
animation-name: rfPill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes rfDot {
0% {
transform: scale(0);
background: transparent;
border-color: rgba(23, 20, 26, 0.2);
}
18%, 84% {
transform: scale(1);
background: #5b3fc4;
border-color: #5b3fc4;
}
88%, 100% {
transform: scale(0);
background: transparent;
border-color: rgba(23, 20, 26, 0.2);
}
}
@keyframes rfPill {
0%, 12% {
transform: translateY(4px);
opacity: 0;
}
30%, 84% {
transform: translateY(0);
opacity: 1;
}
92%, 100% {
transform: translateY(4px);
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.mk__card td {
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.mk__brand {
padding-top: 0 !important;
font-size: 9px !important;
}
.mk__badge {
width: 22px;
height: 22px;
}
.mk__title {
font-size: 11px !important;
padding-top: 0 !important;
}
.mk__body {
font-size: 8px !important;
padding-bottom: 0 !important;
}
.mk__code {
font-size: 10px !important;
padding: 0 12px !important;
}
.mk__copy {
font-size: 9px !important;
padding: 0 8px !important;
margin-left: 4px !important;
}
.mk__dot {
width: 8px;
height: 8px;
}
.mk__dotslabel {
font-size: 8px !important;
}
.mk__dotsrow {
padding-top: 4px !important;
}
.mk__ctawrap {
padding-top: 4px !important;
}
.mk__cta {
padding: 0 20px !important;
font-size: 10px !important;
}
.mk__live {
min-height: 0;
font-size: 8px !important;
}
.mk__footer {
font-size: 7px !important;
padding-top: 0 !important;
padding-bottom: 4px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mk.is-demo .d1,
.mk.is-demo .d2,
.mk.is-demo .mk__code {
animation: none !important;
transform: none;
opacity: 1;
}
}var mk = document.getElementById('mk');
var copybtn = document.getElementById('copybtn');
var live = document.getElementById('live');
copybtn.addEventListener('click', function () {
mk.classList.remove('is-demo');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText('GODRICH-9K2F').then(function () {
copybtn.textContent = '복사됨';
live.textContent = '코드를 복사했어요';
}, function () {
live.textContent = '복사 실패 — 코드를 직접 선택하세요';
});
}
});import { useState } from "react";
import styles from "./ReferralInvite.module.scss";
export type ReferralInviteProps = { duration?: number; easing?: string; color?: string };
export default function ReferralInvite({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#b9a0ff" }: ReferralInviteProps) {
const [demo, setDemo] = useState(true);
const [copied, setCopied] = useState(false);
const [live, setLive] = useState("");
const copyCode = () => {
setDemo(false);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText("GODRICH-9K2F").then(
() => { setCopied(true); setLive("코드를 복사했어요"); },
() => { setLive("복사 실패 — 코드를 직접 선택하세요"); }
);
}
};
return (
<main className={styles.stage}>
<div className={styles.mk}>
<div className={styles.card}>
<div className={styles.brand}>◆ Acme</div>
<div className={styles.hero}>
<span className={styles.badge} style={{ background: color }} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M168,144a40,40,0,1,1-40-40A40,40,0,0,1,168,144ZM64,56A32,32,0,1,0,96,88,32,32,0,0,0,64,56Zm128,0a32,32,0,1,0,32,32A32,32,0,0,0,192,56Z" opacity="0.2"/><path d="M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1,0-16,24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.85,8,57,57,0,0,0-98.15,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z"/></svg>
</span>
</div>
<div className={styles.title}>친구를 초대하면 둘 다 10,000원</div>
<div className={styles.body}>아래 코드로 친구가 가입하면, 두 분 모두 쿠폰을 드려요.</div>
<div className={styles.coderow}>
<span className={styles.code} style={demo ? { animationName: "rfPill", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite" } : undefined}>GODRICH-9K2F</span>
<button type="button" className={styles.copy} style={{ background: color }} aria-label="추천 코드 복사" onClick={copyCode}>{copied ? "복사됨" : "복사"}</button>
</div>
<div className={styles.dotsrow}>
<div className={styles.dots} role="img" aria-label="초대 3칸 중 2칸 완료">
{[0, 1, 2].map((i) => (
<span
key={i}
className={`${styles.dot} ${i < 2 ? styles.dotFill : ""} ${demo && i === 0 ? styles.d1 : ""} ${demo && i === 1 ? styles.d2 : ""}`}
style={demo && i < 2 ? { animationName: "rfDot", animationDuration: "2s", animationTimingFunction: easing, animationIterationCount: "infinite", animationDelay: `${i === 0 ? 160 : 380}ms`, animationFillMode: "backwards" } : undefined}
></span>
))}
</div>
<span className={styles.dotslabel}>2 / 3 초대 완료 — 한 명 더!</span>
</div>
<div className={styles.ctawrap}>
<a href="#" role="button" className={styles.cta} onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>친구 초대하기</a>
</div>
<div className={styles.live} aria-live="polite">{live}</div>
<div className={styles.footer}>Acme Inc · 서울 · <a href="#" className={styles.unsub}>수신거부</a></div>
</div>
</div>
</main>
);
}09 · 친구 추천 코드 메일 — 측정 누적면적 3.049% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
친구 추천 캠페인과 적립 보상 안내에 씁니다.
// 점 두 개가 차례로 차오른다 — 알약 글자 간격이 넓어 코드가 한눈에 읽힌다
.mk.is-demo .d1 { animation-name: rfDot; animation-duration: 2s;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
animation-delay: 160ms; animation-fill-mode: backwards; }
.mk.is-demo .d2 { animation-name: rfDot; animation-duration: 2s;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
animation-delay: 380ms; animation-fill-mode: backwards; }
@keyframes rfDot {
0% { transform: scale(0); background: transparent; border-color: rgba(23,20,26,.2); }
18%, 84% { transform: scale(1); background: #5b3fc4; border-color: #5b3fc4; }
88%, 100% { transform: scale(0); background: transparent; border-color: rgba(23,20,26,.2); }
}
어디서 깨지는가 — 함정
이 장르의 함정은 브라우저가 아니라 메일 앱에서 터집니다. 이 데모의 움직임을 메일 본문에 그대로 넣으면 안 되는데, caniemail 실측(2026-04-07 판)에서 CSS 애니메이션을 온전히 지원하는 메일 환경은 24.39%, 조건부 지원을 합쳐도 31.71% 이기 때문입니다. 그래서 아홉 개 모두 뼈대를 먼저 정적으로 완성해 두었습니다 — 미리보기 루프(.is-demo)를 떼어낸 상태에서 숫자·합계·체크·버튼이 전부 제자리에 보이고, 격자 카드에 실리는 루프는 그 위에 얹은 웹 미리보기 연출입니다. 메일로 보낼 때는 이 정적 상태가 곧 본문이고, 웹 버전 안내 페이지나 랜딩에서만 루프를 살립니다. 지원 범위 안에서도 환경마다 갈립니다 — 1&1 웹메일은 % 키프레임을 못 읽고 라포스트·SFR 은 @keyframes 에 접두를 잘못 붙인다는 같은 페이지의 기록이 있어, 키프레임에는 from/to 만 쓰는 편이 방어가 됩니다. 어두운 모드는 더 까다로운데 prefers-color-scheme 지원은 41.86%로 야후메일·AOL 은 이 쿼리를 통째로 지워버리고, 새 아웃룩 계열은 이 쿼리 대신 자기네 데이터 속성(data-ogsc 따위)을 붙여 옵니다. 그래서 08 은 시스템 설정을 따르는 블록과 별개로, 어두운 배경에서도 숫자 색을 하늘색(8.07:1)으로 밝히는 쪽을 택했습니다.
두 번째 함정은 스크립트가 다시 쓰는 글자였습니다. 04 의 합계는 처음에 toLocaleString('ko-KR') + '원' 하나로 박아뒀는데, 영어·일본어 판 문구 사전이 화면의 글자를 바꿔도 수량 단추를 누르는 순간 스크립트가 다시 쓴 한국어 「원」이 돌아옵니다. 사전 치환은 처음 한 번뿐이고 이후의 재계산은 스크립트 몫이기 때문입니다. document.documentElement.lang 을 읽어 언어별 표기(원·₩·ウォン)로 바꾸는 money() 를 따로 두고 나서야 세 판의 합계가 각자 언어를 지켰습니다. 같은 자리에서 간격 규칙도 걸렸습니다 — 셀 사이 2px 간격을 임의로 줬더니 4px 격자 검사에 걸렸고, 0 으로 내려 순서를 지켰습니다. 메일 카드는 줄이 좁아 몇 픽셀이 어긋나면 바로 표 세로줄이 삐걱거리므로, 격자를 지키는 일이 브라우저 페이지보다 더 눈에 띕니다.
세 번째는 합계의 컷 교체입니다. 0원 에서 41,600원 으로 바뀌는 순간은 두 장을 겹쳐 놓고 steps(1, end) 로 갈아끼웠는데, 처음에는 두 장을 페이드로 겹쳤다가 중간 장마다 반쯤 겹친 금액이 유령으로 찍혔습니다. 컷으로 바꾼 뒤로는 전환 장이 두 개뿐이라 유령이 없고, 상품 두 줄의 슬라이드가 옆에서 프레임을 채워 움직인 프레임 17/23 을 유지합니다. 캡처 창이 2초인 까닭에 모든 루프 주기도 2초의 약수로 맞췄고, 복귀는 86~88% 안에 끝내 마지막 샘플 바깥으로 새는 장면이 없습니다. 메일이 아니라 페이지 위에서 신뢰를 세우는 섹션은 믿음 가는 랜딩 섹션에 모여 있습니다.
접근성
색은 짐작이 아니라 WCAG 2.1 상대휘도 공식으로 직접 계산했고, 통과한 조합만 글자에 썼습니다. 맨 아래 네 줄이 이 팔레트에서 글자로 쓰지 않기로 한 조합입니다.
| 앞 | 뒤 | 실측 대비 | 판정 |
|---|---|---|---|
| 잉크 #17141a | 흰 카드 #ffffff | 18.24:1 | 통과 |
| 잉크 62% (합성 #6f6d71) | 흰 카드 | 5.12:1 | 통과 |
| 짙은 파랑 #1d4fbb | 쿠폰 바탕 합성 | 6.73:1 | 통과 |
| 크림 #fff7e6 | CTA 파랑 #2a5fd8 (01·04·08 글자) | 5.29:1 | 통과 |
| 크림 #fff7e6 | 잉크 #17141a (카운트다운 숫자) | 17.11:1 | 통과 |
| 잉크 #17141a | 오렌지 #ff4d1f (CTA·날짜 카드) | 5.50:1 | 통과 |
| 그래스 #0f7a52 | 흰 카드 (진행 막대) | 5.35:1 | 통과 |
| 크림 #fff7e6 | 그래스 #0f7a52 (체크 아이콘) | 5.02:1 | 통과 |
| 짙은 보라 #5b3fc4 | 흰 카드 (점) · 알약 바탕 합성 (추천 코드) | 7.11 · 6.44:1 | 통과 |
| 잉크 #17141a | 라일락 #b9a0ff (복사 단추 글자) | 8.28:1 | 통과 |
| 하늘 #6ec8ff | 타일 합성 #2a262a (다크 모드 숫자) | 8.07:1 | 통과 |
| 크림 55% 합성 | 타일 합성 #2a262a (다크 모드 라벨) | 5.28:1 | 통과 |
| 오렌지 #ff4d1f | 흰 카드 | 3.32:1 | 별 채움(그림 요소 3:1) 통과 · 글자로는 안 씀 |
| 크림 #fff7e6 | 오렌지 #ff4d1f | 3.11:1 | 미달 — 글자로 쓰지 않는다 |
| 라일락 #b9a0ff | 흰 카드 | 2.20:1 | 미달 — 글자로 쓰지 않는다 |
| 민트 #4cd4a6 | 흰 카드 | 1.86:1 | 미달 — 썸네일 장식으로만 쓴다 |
줄인 움직임 설정(prefers-reduced-motion: reduce)을 켠 독자에게는 아홉 개 모두 연출만 멈추고 값과 상태를 그대로 둡니다. 01 은 기사 세 줄이 다 올라온 채로 서고, 02 는 상자가 멈춰도 코드가 그대로 읽히며, 03 은 초 칸이 첫 숫자에 멈추고 막대는 찬 상태로 둡니다. 04 는 상품 줄과 합계가 완성 상태로 서고, 05 는 참석 버튼이 체크를 붙인 채 멈추고, 06 은 네 번째 체크가 그려진 채로 있다가 줄을 누르면 진짜로 완료됩니다. 07 은 별이 회색으로 돌아와 누르면 점수가 매겨지고, 08 은 카드의 떠오름만 멈추고 숫자는 스크립트가 굴려 마지막 값에서 멈추며, 09 는 점 둘이 찬 상태로 고정됩니다. 아홉 벌의 완성본은 압축 파일에 들어 있으며, 열 때 필요한 비밀번호는 gf6nh9h4 입니다.
화면 낭독기 쪽 배치는 이렇게 잡았습니다. 카드 뼈대는 화면 낭독기가 표를 읽지 않도록 role="presentation" 을 단 표로 짰고, 눌러야 하는 것은 전부 단추라 엔터로도 동작합니다. 03 의 남은 시간은 role="timer" 와 aria-label 한 줄로 읽히고 겹친 숫자 장들은 aria-hidden 입니다. 04 의 수량 단추는 늘리기·줄이기 각각 aria-label 을 갖고, 05 의 참석 버튼들은 aria-pressed 로, 06 의 진행 막대는 aria-valuenow 로, 07 의 별은 radiogroup 안 radio 로 각자 상태를 알립니다. 02 와 09 의 복사 결과는 aria-live 영역이 조용히 알리고, 별과 단추는 :focus-visible 링으로 키보드 자리를 보여줍니다. 지원 범위의 근거는 caniemail 의 CSS 애니메이션 조사와 prefers-color-scheme 조사, 클립보드는 MDN 의 Clipboard.writeText 문서, 대비 기준은 W3C 의 최소 명도 대비 해설을 따랐습니다. 마케팅 메일 소재를 더 둘러보려면 커머스 분류로, 사고 나서 오는 메일은 이메일 템플릿 디자인 9종에, 이 사이트가 코드를 어떻게 검증하는지는 소개에 적어 두었습니다.
FAQ
이메일 뉴스레터 디자인의 이 애니메이션이 메일 앱에서도 돌아가나요?
움직임은 메일 본문이 아니라 웹 미리보기의 것입니다. caniemail 실측(2026-04-07)에서 CSS 애니메이션을 온전히 지원하는 메일 환경은 24.39%, 조건부를 합쳐도 31.71% 라 메일에서는 정적 상태로 보내는 것이 정답입니다. 아홉 개 모두 루프를 뗀 정적 상태가 완성된 화면이라 그대로 메일이 되고, 랜딩·안내 페이지에서 루프를 살려 보여줍니다. 이 정적 뼈대를 매장 화면에 옮겨 쓰는 이야기는 키오스크 주문 화면이 손가락 크기 기준으로 풉니다.
아웃룩에서는 뭐가 제일 먼저 깨지나요?
어두운 모드 지원이 가장 갈립니다. prefers-color-scheme 을 지원하는 메일 환경은 41.86% 이고, 야후메일과 AOL 은 이 쿼리를 지워버리며, 새 아웃룩 계열은 자기네 데이터 속성으로 대신한다고 caniemail 에 기록돼 있습니다. 색을 이 쿼리 하나에만 걸지 말고 밝은 쪽을 기본으로 두고 어두운 쪽을 덧붙이는 순서가 안전합니다. 08 이 그 구조입니다.
마케팅 메일과 거래 메일은 어떻게 나눠서 쓰나요?
사고 나서 오는 영수증·배송 확인·보안 알림은 이메일 템플릿 디자인 9종이, 이번 편은 팔기 위해 먼저 보내는 뉴스레터·쿠폰·리마인드가 몫입니다. 실제 캠페인에서는 01 로 담아 02 로 당기고 04 로 붙잡는 식으로 섞어 씁니다. 거래 메일은 정보가 먼저, 마케팅 메일은 한 가지 행동이 먼저라는 점이 구조를 가릅니다.









