08휴면 고객 재방문 메일
그동안 쌓인 숫자 세 칸이 0에서 실제 값까지 굴러 올라가고 아래 버튼이 떠오르며, 구독 해지 링크는 같은 화면에 작게 남습니다.
쓰임3개월 미방문 고객 재활성, 구독 갱신 안내
requestAnimationFrametranslateYprefers-color-scheme

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


