メルマガ デザイン 9種 — Outlookでも崩れない
メルマガ デザインとは、売るために送るメール — ニュースレター、クーポン、タイムセール、リマインド — の見た目を組み立てる仕事です。この9つはテーブル(table)の縦罫を骨組みにしたHTMLとCSSだけで作ってあり、Outlookでも崩れない貼り付け方そのままで動きます。
01 newsletter
02 promo
03 flash-sale
04 abandoned-cart
05 event-invite
06 onboarding
07 survey
08 reengagement
09 referral
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ニュースレター ダイジェスト
- 02 割引クーポンメール
- 03 タイムセール残り時間バー
- 04 カート放棄リマインド
- 05 イベント招待・出欠
- 06 オンボーディング チェックリスト
- 07 星評価アンケートメール
- 08 休眠顧客の呼び戻しメール
- 09 友達紹介コードメール
並び順は ニュースレター デザイン の流行ではなく、一人の読者がメールを受け取る順です。まず定期的に来るもの — 記事3行が順に上がってくるダイジェスト(01)から始まります。次は買いたくさせる3つ — コードを押してコピーするクーポン(02)、残り時間のバーが右端を軸に縮むタイムセール(03)、置いてきたカートを見せ直すリマインド(04)です。その次は関係を尋ねる3つ — 出席・未定・欠席の3ボタンで招待(05)、登録直後のチェックリスト(06)、星ひとつで満足度を問う設問(07)です。最後の2つは呼び戻しと広がり — 離れている間の数字が転がり上がる再訪メール(08)、紹介コードと3枠中2枠の招待ドット(09)です。舞台色はインク3・イエロー2・オレンジ2・白2で回して明るさを均し、隣り合う2つがまぶしすぎません。購入後に届く領収書・配送・セキュリティ通知はメールテンプレート デザイン 9種が既に扱っており、今回は、売るために先に送るメールの番です。メールの一番下で配信停止と法的な案内をまとめる席はWebと同じ問題で、フッター デザインがその軸をまとめています。
01ニュースレター ダイジェスト
メルマガ デザイン テンプレート で何が見えるか
タイトルの下で記事3行が120ms間隔で下から順に上がり、行ごとに読了時間バッジが付いて何から読むかを選びやすくします。行を押すと既読に変わり、行はループの大半で立ち続けるので、どの瞬間を切り取っても空画面になりません(面積変化10.68%、23フレーム中13フレームが動く)。

<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">太い見出し1つで画面の順序を作る方法</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
週刊ニュースレター、コンテンツ配信メールに使います。
// 行が順に上がる — 遅延のある行は 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でコピーされ、表示が1フレームで「コピーしました」に変わり、結果は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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
セール告知、初回購入クーポンに使います。
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タイムセール残り時間バー
どんな動きか
秒のマスは重ねた3枚の数字をsteps(1, end)で1フレームごとに切り替え — クロスフェードは使わないので半透明の幽霊フレームがありません — 下のバーはtransform-origin: rightを軸に縮んで締切が近づくことを示します。23フレーム中22フレームが動き、全フレームが動くのは08だけです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
タイムセール告知、締切間近リマインドに使います。
// 秒の切り替え — 重ねた3枚を 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カート放棄リマインド
何が見えるか
残した商品2行が左から滑り込み、合計が1フレームで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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ECのカートリマインド、在庫通知に使います。
// 金額表記はページの言語に従う(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イベント招待・出欠
画面で起きること
左の日付カードの横に出席・未定・欠席の3ボタンが並び、ループでは出席ボタンが押された状態へカットで切り替わり、チェックアイコンが跳ね上がります。ボタンは実際に押せ、aria-pressedが選択を伝え、別の1つを押すと前の選択が解除されます。

<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">秋の定例会、2回目</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ウェビナー・オフラインイベントの招待、予約確認に使います。
// 出席ボタンのカット — 実クリックは滑らかな 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オンボーディング チェックリスト
どんな動きか
5マスの進捗バーが3マスまで埋まり、4行目のチェックがstroke-dashoffsetで線に沿って描かれます。行を押すと4マス目が実際に埋まり、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
登録直後のステップメール、機能紹介シリーズに使います。
.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星評価アンケートメール
何が見えるか
星5つが左から160ms間隔で埋まって4つ目で止まり、お礼の一行が上がってきて回答がメール内で完結します。星は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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
購入後の満足度調査、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休眠顧客の呼び戻しメール
画面で起きること
離れている間にたまった数字3マスがrequestAnimationFrameで0から実際の値まで転がり上がり(1.1秒)、下のボタンが浮かびます。キャプチャ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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
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友達紹介コードメール
どんな動きか
紹介コードのピルの下で点3つが順に埋まり2つ目まで進み、3つ目が空いたまま — あと1人招待すれば達成だと示します。遅延のある点はanimation-fill-mode: backwardsで0フレームの点滅を防ぎ、コードは隣のボタンを押してコピーでき、結果が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 招待完了 — あと1人!</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
友達紹介キャンペーン、特典進捗の案内に使います。
// 点2つが順に埋まる — ピルの字間を広く取ってコードが一目で読める
.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%だからです。そこで9つすべて、骨組みを先に静的に完成させました — プレビューループ(.is-demo)を外した状態でも数字・合計・チェック・ボタンが全部そこにあり、格子カードで回るループはその上に重ねたウェブ用の演出です。メールとして送るのはこの静的状態そのもので、ループが生きるのはウェブ版の案内ページやランディングだけです。支える側の中でも環境ごとに割れます — 1&1のウェブメールは%キーフレームを読めず、LaPosteとSFRは@keyframesに接頭を付け損なうと同じページに記録されているので、キーフレームはfrom/toだけにしておくのが安い防御です。ダークモードはさらに厳しく、prefers-color-scheme のサポートは41.86%で、Yahoo!メールとAOLはこのクエリを丸ごと消し、新しいOutlook系は自前のデータ属性(data-ogscなど)で代えてきます。だから08は明るいカードを基本に置き、暗いブロックの中でのみ数字色を空色(8.07:1)へ持ち上げています。
2つ目は、スクリプトが書き戻す文字でした。04の合計は最初toLocaleString('ko-KR') + '원'ひとつで組みましたが、ja・en のページの文言辞書が画面の文字を差し替えても、個数ボタンを押した瞬間に計算し直された韓国語の「원」が戻ってきます。辞書の差し替えは最初の1回きりで、以降の再計算はスクリプトの仕事だからです。document.documentElement.langを読んで表記(원·₩·ウォン)を切り替える小さなmoney()を置いて初めて、3つの版の合計がそれぞれの言語を守りました。同じ場所で間隔の規則にも引っかかりました — セル間の2pxを勝手に空けたら4pxグリッド検査に掛かり、0に下げて律動を戻しました。メールカードは狭いので、数ピクセルのズレでも表の縦罫の狂いが目に付き、グリッドを守ることがブラウザのページより重要になります。
3つ目は合計のカットです。0ウォンから41,600ウォンへの切替を最初は2枚のラベルをフェードで重ねて行ったところ、中間のフレームすべてに半分重なった幽霊の金額が写りました。steps(1, end)で交換すると状態は2つだけで幽霊は消え、横で滑り込む商品2行と合わせても動くのは23フレーム中17フレームにとどまります。キャプチャ窓が2秒なので、ループ周期はすべて2秒の約数に揃え、戻りは86〜88%の間に収めて、最後のサンプルにはみ出す場面をなくしました。メールではなくページの上で信頼を積む区画は、信頼を作るLPセクションに集まっています。
アクセシビリティ
色は推測ではなく WCAG 2.1 の相対輝度式で直接計算し、通過した組み合わせだけが文字を担います。下の4行が、このパレットで文字として使わない組み合わせです。
| 前景 | 背景 | 実測コントラスト | 判定 |
|---|---|---|---|
| インク #17141a | 白カード #ffffff | 18.24:1 | 合格 |
| インク62%(合成 #6f6d71) | 白カード | 5.12:1 | 合格 |
| 濃紺 #1d4fbb | クーポン地の合成色 #f3f6fe | 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 | 白カード(点)・ピル地の合成 #f5f2ff(コード) | 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)では、9つすべてが演出だけを止めて値と状態を残します。01は行3つが上がりきったまま立ち、02はボックスが止まってもコードがそのまま読め、03は秒が最初の値で止まりバーは満ちた状態です。04は商品と合計が完成形で並び、05は出席ボタンにチェックが付いたまま止まり、06は4つ目のチェックが描かれたまま止まり、押せば本当に完了します。07は星が灰色に戻って押せば点数が付き、08はカードの浮上だけ止まり数字はスクリプトが最終値まで転がり、09は点2つが埋まった状態で固定されます。9つの完成コードは圧縮ファイルに入っており、開けるのに必要なパスワードは gf6nh9h4 です。
画面読み上げ側の構成です。カードの骨組みはrole="presentation"を付けた表なのでデータ表として読まれず、押すものはすべてボタンなので Enter でも動きます。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
メルマガ デザイン html のアニメーションはメールアプリでも動きますか
動きはウェブプレビュー側のものです。caniemail の実測(2026-04-07)ではCSSアニメーションを完全に支えるメール環境は24.39%、部分的サポートを合わせても31.71%なので、送るのは静的状態が正解です。9つすべてループを外しても完成した画面なのでそのままメールになり、ランディングや案内ページでループを生かして見せます。この静的な骨組みを店頭の画面へ移す話は、タッチパネル 注文 画面が指の大きさの基準で解いています。
Outlookでは何が最初に壊れますか
ダークモードです。prefers-color-schemeが届くメール環境は41.86%で、Yahoo!メールとAOLはこのクエリを消し、新しいOutlook系は独自のデータ属性で代えると caniemail に記録があります。色をこのクエリだけに掛けず、明るい側を基本に暗い側を上に重ねる順番が安全です。08がその構造です。
マーケティングメールと取引メールはどう使い分けますか
領収書・配送・セキュリティ通知はメールテンプレート デザイン 9種が、今回は、売るために先に送るニュースレター・クーポン・リマインドが担います。実際のキャンペーンでは01で詰めて02で引き、04で受け止めるように混ぜます。取引メールは情報が先、マーケティングメールはひとつの動作が先という点が構造を分けます。









