GODRICH

뉴스레터 디자인 9종 — 메일 앱에서 안 깨지는 HTML

뉴스레터 디자인은 정기적으로 오는 마케팅 메일의 겉모습을 짜는 일입니다. 여기 아홉 가지는 다이제스트·쿠폰·타임세일·장바구니 리마인드 같은 자주 보내는 마케팅 메일을 HTML 과 CSS 로 짜 둔 것이라, 메일 앱에서 안 깨지는 표(table) 세로 줄을 뼈대로 삼아 붙여 넣으면

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개를 놓은 순서는 유행이 아니라 한 사람이 메일을 받는 순서입니다. 먼저 정기적으로 오는 것 — 읽을 거리를 골라 담은 다이제스트(01)로 시작합니다. 다음은 사고 싶게 만드는 셋 — 쿠폰 코드를 눌러 복사하는 메일(02), 남은 시간이 줄어드는 타임세일 띠(03), 두고 간 장바구니를 다시 보여주는 리마인드(04)입니다. 그다음은 관계를 묻는 셋 — 행사 참석 여부를 세 버튼으로 묻고(05), 가입 뒤 남은 단계를 체크리스트로 세우며(06), 별 하나로 만족도를 묻습니다(07). 마지막 둘은 돌아오게 하고 퍼지게 하는 것 — 떠나 있던 동안의 숫자를 굴려 보여주고(08), 친구 초대 코드로 퍼뜨립니다(09). 무대 색은 잉크 셋·옐로 둘·오렌지 둘·흰 바탕 둘로 돌려 밝기를 고르게 맞췄고, 카드 폭은 전부 최대 400px 로 아이폰 세로 화면에서 양쪽이 잘리지 않습니다. 거래 확인·영수증 같은 사고 나서 오는 메일은 이메일 템플릿 디자인 9종이 이미 다루었고, 이번 편은 팔기 위해 보내는 메일 차례입니다.

01뉴스레터 다이제스트

제호 아래 기사 세 줄이 0.12초 간격으로 아래에서 차례로 올라오고, 줄마다 읽는 시간 배지가 붙어 무엇부터 읽을지 고를 수 있게 합니다. 줄을 누르면 읽음 표시로 바뀌는데, 올라온 뒤로도 한동안 그대로 서 있어 루프의 어느 장면을 잡아도 화면이 비지 않습니다(누적면적 10.75%, 움직인 프레임 13/23). 주간 뉴스레터와 콘텐츠 구독 메일에 씁니다.

table role=presentationanimation-fill-mode: backwardsanimation-delay
// 세 줄이 차례로 올라온다 — 지연이 걸린 줄은 backwards 로 0프레임 번쩍임을 막는다
.mk.is-demo .mk__row {
  animation-name: nlRise;
  animation-duration: 2s;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
}
.mk.is-demo .mk__row:nth-child(2) { animation-delay: 120ms; animation-fill-mode: backwards; }
.mk.is-demo .mk__row:nth-child(3) { animation-delay: 240ms; animation-fill-mode: backwards; }

@keyframes nlRise {
  0%   { opacity: 0; transform: translateY(14px); }
  30%  { opacity: 1; transform: translateY(0); }
  80%  { opacity: 1; transform: translateY(0); }
  88%  { opacity: 0; transform: translateY(8px); }
  100% { opacity: 0; transform: translateY(14px); }
}

02할인 쿠폰 메일

점선 상자 안의 쿠폰 코드를 누르면 navigator.clipboard.writeText 로 코드가 복사되고 상자 글자가 한 프레임에 「복사됨」으로 바뀌며, 복사 결과는 aria-live 영역이 알립니다. 루프에서는 상자가 한 번 부풀었다 돌아오는 것으로 코드 자리를 알려줍니다. 시즌 할인 안내와 첫 구매 쿠폰 발송에 씁니다.

navigator.clipboard.writeTextsteps(1, end)aria-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');
    });
  }
});

03타임세일 남은 시간 띠

초 칸 숫자는 겹쳐 놓은 세 장을 steps(1, end) 로 한 프레임에 갈아끼우고, 그 아래 막대가 transform-origin: right 을 기준으로 줄어들어 마감이 다가오는 것을 보여줍니다. 숫자가 컷으로만 바뀌는 덕분에 반투명 유령 장이 없고, 움직인 프레임은 22/23 로 쉬는 장이 한 장뿐입니다. 타임세일 안내와 마감 임박 리마인드에 씁니다.

transform-origin: rightgrid-template-columnssteps(1, end)
// 초 칸 숫자 교체 — 겹쳐 놓은 세 장을 steps 컷으로만 바꾼다 (교차 페이드 금지)
.mk.is-demo .mk__d1 { animation-name: fsD1; animation-duration: 2s;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__d2 { animation-name: fsD2; animation-duration: 2s;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__d3 { animation-name: fsD3; animation-duration: 2s;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.mk.is-demo .mk__fill { animation-name: fsBar; animation-duration: 2s;
  animation-timing-function: linear; animation-iteration-count: infinite; }

@keyframes fsBar {
  0%  { transform: scaleX(1); }
  78% { transform: scaleX(.16); }
  82% { opacity: 0; transform: scaleX(.16); }
  84% { opacity: 0; transform: scaleX(1); }
  90%, 100% { opacity: 1; transform: scaleX(1); }
}

04장바구니 이탈 리마인드

담아둔 상품 두 줄이 왼쪽에서 밀려 들어오고, 합계가 한 프레임에 0원에서 41,600원으로 갈립니다. 수량 단추를 누르면 스크립트가 합계를 다시 계산해 toLocaleString 천 단위 표기로 바로 따라오고, 상품 두 줄이 슬라이드로 들어오는 동안 누적면적 8.19% 를 움직입니다. 쇼핑몰 장바구니 리마인드와 재고 알림에 씁니다.

translateXtoLocaleString-webkit-text-size-adjust
// 금액 표기는 페이지 언어를 따라간다 (ko 원 · en ₩ · ja ウォン)
function money(n) {
  var lang = document.documentElement.lang || 'ko';
  var digits = n.toLocaleString('ko-KR');
  if (lang === 'ja') { return digits + 'ウォン'; }
  if (lang === 'en') { return '₩' + digits; }
  return digits + '원';
}

function recompute() {
  var items = document.querySelectorAll('.mk__item');
  var sum = 0;
  for (var i = 0; i < items.length; i++) {
    var qty = parseInt(items[i].querySelector('.mk__n').textContent, 10) || 0;
    sum += qty * parseInt(items[i].getAttribute('data-price'), 10);
  }
  total.textContent = money(sum);
}

05행사 초대 · 참석 여부

왼쪽 날짜 카드 옆에 참석·미정·불참 버튼이 서고, 루프에서 참석 버튼이 눌린 상태로 컷 전환되며 체크 아이콘이 튀어 오릅니다. 버튼 세 개는 실제로 눌리며 aria-pressed 로 선택을 알리고, 다른 것을 누르면 앞의 선택이 풀립니다. 웨비나·오프라인 행사 초대와 예약 확정 요청에 씁니다.

aria-pressedcubic-beziertransform: scale
// 참석 버튼 컷 전환 — 실제 클릭은 부드러운 is-on 전환, 컷은 격자 미리보기 루프에만
.mk.is-demo .mk__yes {
  animation-name: evYes;
  animation-duration: 2s;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}
.mk.is-demo .mk__yes .mk__check {
  animation-name: evCheck;
  animation-duration: 2s;
  animation-timing-function: cubic-bezier(.2, 1.4, .4, 1);
  animation-iteration-count: infinite;
}

@keyframes evCheck {
  0%, 34%  { transform: scale(0); }
  44%      { transform: scale(1.25); }
  52%, 84% { transform: scale(1); }
  85%, 100% { transform: scale(0); }
}

06온보딩 체크리스트

다섯 칸 진행 막대가 세 칸까지 차 있고, 네 번째 줄의 체크 표시가 stroke-dashoffset 으로 선을 따라 그려집니다. 네 번째 줄을 누르면 막대 네 칸이 실제로 차오르고 aria-valuenow 가 60 에서 80 으로 바뀌며, 진행 막대는 게이지 규칙대로 grid-template-columns: repeat(5, 1fr) 로 균등하게 짜서 칸이 밀리지 않습니다. 가입 직후 연속 메일과 기능 소개 시리즈에 씁니다.

stroke-dashoffsetgrid-template-columnsaria-valuenow
.mk__line { stroke-dasharray: 1; stroke-dashoffset: 1; }
.mk__next.is-done .mk__line { stroke-dashoffset: 0; }

// 미리보기 루프: 체크가 8%부터 38%까지 선을 따라 그려진다
.mk.is-demo .mk__line {
  animation-name: obDraw;
  animation-duration: 2s;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
}

@keyframes obDraw {
  0%, 8%    { stroke-dashoffset: 1; }
  38%       { stroke-dashoffset: 0; }
  84%       { stroke-dashoffset: 0; }
  86%, 100% { stroke-dashoffset: 1; }
}

07별점 설문 메일

별 다섯 개가 왼쪽부터 0.16초 간격으로 차오르다 네 번째에서 멈추고, 감사 문구 한 줄이 올라와 답이 메일 안에서 끝납니다. 별은 role="radiogroup" 안의 라디오라 화면 낭독기에도 선택으로 읽히고, 누른 별까지 색이 채워지며 문구가 점수로 바뀝니다. 구매 후 만족도 조사와 NPS 수집 메일에 씁니다.

role=radiogroupanimation-delay:focus-visible
.mk.is-demo .mk__star:nth-child(1) { animation-name: svFill; animation-duration: 2s;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 120ms; }
.mk.is-demo .mk__star:nth-child(2) { animation-name: svFill; animation-duration: 2s;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 280ms; }
.mk.is-demo .mk__star:nth-child(3) { animation-name: svFill; animation-duration: 2s;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 440ms; }
.mk.is-demo .mk__star:nth-child(4) { animation-name: svFill; animation-duration: 2s;
  animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-delay: 600ms; }

@keyframes svFill {
  0%        { color: rgba(23,20,26,.22); }
  30%, 84%  { color: $stage-orange; }
  86%, 100% { color: rgba(23,20,26,.22); }
}

08휴면 고객 재방문 메일

그동안 쌓인 숫자 세 칸이 requestAnimationFrame 으로 0에서 실제 값까지 굴러 올라가고(1.1초), 아래 버튼이 떠오릅니다. 움직인 프레임은 23/23 로 캡처 내내 숫자가 변하며, 시스템이 어두운 모드면 prefers-color-scheme 블록이 카드를 자동으로 뒤집고 숫자 색도 하늘색으로 밝힙니다. 3개월 미방문 고객 재활성화와 구독 갱신 안내에 씁니다.

requestAnimationFrametranslateYprefers-color-scheme
var counters = document.querySelectorAll('.mk__num');
var DURATION = 1100;
for (var c = 0; c < counters.length; c++) {
  counters[c].setAttribute('data-start', String(performance.now()));
}
function rollNumber(now) {
  var allDone = true;
  for (var i = 0; i < counters.length; i++) {
    var el = counters[i];
    var target = parseInt(el.getAttribute('data-target'), 10);
    var started = parseFloat(el.getAttribute('data-start'));
    var t = Math.min(1, (now - started) / DURATION);
    var eased = 1 - Math.pow(1 - t, 3);
    el.textContent = Math.round(target * eased).toLocaleString('ko-KR');
    if (t < 1) { allDone = false; }
  }
  if (!allDone) { requestAnimationFrame(rollNumber); }
}
requestAnimationFrame(rollNumber);

09친구 추천 코드 메일

추천 코드 알약 아래 점 세 개가 차례로 차올라 두 개까지 채워지고, 세 번째가 비어 있어 한 명만 더 초대하면 된다는 것을 보여줍니다. 지연이 걸린 점은 animation-fill-mode: backwards 로 첫 장 번쩍임을 막았고, 코드는 옆의 복사 버튼을 눌러 복사되며 결과가 aria-live 로 알립니다. 친구 추천 캠페인과 적립 보상 안내에 씁니다.

animation-fill-mode: backwardsletter-spacingaria-label
// 점 두 개가 차례로 차오른다 — 알약 글자 간격이 넓어 코드가 한눈에 읽힌다
.mk.is-demo .d1 { animation-name: rfDot; animation-duration: 2s;
  animation-timing-function: $ease-pop; animation-iteration-count: infinite;
  animation-delay: 160ms; animation-fill-mode: backwards; }
.mk.is-demo .d2 { animation-name: rfDot; animation-duration: 2s;
  animation-timing-function: $ease-pop; animation-iteration-count: infinite;
  animation-delay: 380ms; animation-fill-mode: backwards; }

@keyframes rfDot {
  0%        { transform: scale(0); background: transparent; border-color: rgba(23,20,26,.2); }
  18%, 84%  { transform: scale(1); background: #5b3fc4; border-color: #5b3fc4; }
  88%, 100% { transform: scale(0); background: transparent; border-color: rgba(23,20,26,.2); }
}

어디서 깨지는가 — 함정

이 장르의 함정은 브라우저가 아니라 메일 앱에서 터집니다. 이 데모의 움직임을 메일 본문에 그대로 넣으면 안 되는데, caniemail 실측(2026-04-07 판)에서 CSS 애니메이션을 온전히 지원하는 메일 환경은 24.39%, 조건부 지원을 합쳐도 31.71% 이기 때문입니다. 그래서 아홉 개 모두 뼈대를 먼저 정적으로 완성해 두었습니다 — 미리보기 루프(.is-demo)를 떼어낸 상태에서 숫자·합계·체크·버튼이 전부 제자리에 보이고, 격자 카드에 실리는 루프는 그 위에 얹은 웹 미리보기 연출입니다. 메일로 보낼 때는 이 정적 상태가 곧 본문이고, 웹 버전 안내 페이지나 랜딩에서만 루프를 살립니다. 지원 범위 안에서도 환경마다 갈립니다 — 1&1 웹메일은 % 키프레임을 못 읽고 라포스트·SFR 은 @keyframes 에 접두를 잘못 붙인다는 같은 페이지의 기록이 있어, 키프레임에는 from/to 만 쓰는 편이 방어가 됩니다. 어두운 모드는 더 까다로운데 prefers-color-scheme 지원은 41.86%로 야후메일·AOL 은 이 쿼리를 통째로 지워버리고, 새 아웃룩 계열은 이 쿼리 대신 자기네 데이터 속성(data-ogsc 따위)을 붙여 옵니다. 그래서 08 은 시스템 설정을 따르는 블록과 별개로, 어두운 배경에서도 숫자 색을 하늘색(8.07:1)으로 밝히는 쪽을 택했습니다.

두 번째 함정은 스크립트가 다시 쓰는 글자였습니다. 04 의 합계는 처음에 toLocaleString('ko-KR') + '원' 하나로 박아뒀는데, 영어·일본어 판 문구 사전이 화면의 글자를 바꿔도 수량 단추를 누르는 순간 스크립트가 다시 쓴 한국어 「원」이 돌아옵니다. 사전 치환은 처음 한 번뿐이고 이후의 재계산은 스크립트 몫이기 때문입니다. document.documentElement.lang 을 읽어 언어별 표기(원·₩·ウォン)로 바꾸는 money() 를 따로 두고 나서야 세 판의 합계가 각자 언어를 지켰습니다. 같은 자리에서 간격 규칙도 걸렸습니다 — 셀 사이 2px 간격을 임의로 줬더니 4px 격자 검사에 걸렸고, 0 으로 내려 순서를 지켰습니다. 메일 카드는 줄이 좁아 몇 픽셀이 어긋나면 바로 표 세로줄이 삐걱거리므로, 격자를 지키는 일이 브라우저 페이지보다 더 눈에 띕니다.

세 번째는 합계의 컷 교체입니다. 0원 에서 41,600원 으로 바뀌는 순간은 두 장을 겹쳐 놓고 steps(1, end) 로 갈아끼웠는데, 처음에는 두 장을 페이드로 겹쳤다가 중간 장마다 반쯤 겹친 금액이 유령으로 찍혔습니다. 컷으로 바꾼 뒤로는 전환 장이 두 개뿐이라 유령이 없고, 상품 두 줄의 슬라이드가 옆에서 프레임을 채워 움직인 프레임 17/23 을 유지합니다. 캡처 창이 2초인 까닭에 모든 루프 주기도 2초의 약수로 맞췄고, 복귀는 86~88% 안에 끝내 마지막 샘플 바깥으로 새는 장면이 없습니다.

접근성

색은 짐작이 아니라 WCAG 2.1 상대휘도 공식으로 직접 계산했고, 통과한 조합만 글자에 썼습니다. 맨 아래 네 줄이 이 팔레트에서 글자로 쓰지 않기로 한 조합입니다.

실측 대비 판정
잉크 #17141a 흰 카드 #ffffff 18.24:1 통과
잉크 62% (합성 #6f6d71) 흰 카드 5.12:1 통과
짙은 파랑 #1d4fbb 쿠폰 바탕 합성 6.73:1 통과
크림 #fff7e6 CTA 파랑 #2a5fd8 (01·04·08 글자) 5.29:1 통과
크림 #fff7e6 잉크 #17141a (카운트다운 숫자) 17.11:1 통과
잉크 #17141a 오렌지 #ff4d1f (CTA·날짜 카드) 5.50:1 통과
그래스 #0f7a52 흰 카드 (진행 막대) 5.35:1 통과
크림 #fff7e6 그래스 #0f7a52 (체크 아이콘) 5.02:1 통과
짙은 보라 #5b3fc4 흰 카드 (점) · 알약 바탕 합성 (추천 코드) 7.11 · 6.44:1 통과
잉크 #17141a 라일락 #b9a0ff (복사 단추 글자) 8.28:1 통과
하늘 #6ec8ff 타일 합성 #2a262a (다크 모드 숫자) 8.07:1 통과
크림 55% 합성 타일 합성 #2a262a (다크 모드 라벨) 5.28:1 통과
오렌지 #ff4d1f 흰 카드 3.32:1 별 채움(그림 요소 3:1) 통과 · 글자로는 안 씀
크림 #fff7e6 오렌지 #ff4d1f 3.11:1 미달 — 글자로 쓰지 않는다
라일락 #b9a0ff 흰 카드 2.20:1 미달 — 글자로 쓰지 않는다
민트 #4cd4a6 흰 카드 1.86:1 미달 — 썸네일 장식으로만 쓴다

줄인 움직임 설정(prefers-reduced-motion: reduce)을 켠 독자에게는 아홉 개 모두 연출만 멈추고 값과 상태를 그대로 둡니다. 01 은 기사 세 줄이 다 올라온 채로 서고, 02 는 상자가 멈춰도 코드가 그대로 읽히며, 03 은 초 칸이 첫 숫자에 멈추고 막대는 찬 상태로 둡니다. 04 는 상품 줄과 합계가 완성 상태로 서고, 05 는 참석 버튼이 체크를 붙인 채 멈추고, 06 은 네 번째 체크가 그려진 채로 있다가 줄을 누르면 진짜로 완료됩니다. 07 은 별이 회색으로 돌아와 누르면 점수가 매겨지고, 08 은 카드의 떠오름만 멈추고 숫자는 스크립트가 굴려 마지막 값에서 멈추며, 09 는 점 둘이 찬 상태로 고정됩니다. 아홉 벌의 완성본은 압축 파일에 들어 있으며, 열 때 필요한 비밀번호는 gf6nh9h4 입니다.

화면 낭독기 쪽 배치는 이렇게 잡았습니다. 카드 뼈대는 화면 낭독기가 표를 읽지 않도록 role="presentation" 을 단 표로 짰고, 눌러야 하는 것은 전부 단추라 엔터로도 동작합니다. 03 의 남은 시간은 role="timer"aria-label 한 줄로 읽히고 겹친 숫자 장들은 aria-hidden 입니다. 04 의 수량 단추는 늘리기·줄이기 각각 aria-label 을 갖고, 05 의 참석 버튼들은 aria-pressed 로, 06 의 진행 막대는 aria-valuenow 로, 07 의 별은 radiogroupradio 로 각자 상태를 알립니다. 02 와 09 의 복사 결과는 aria-live 영역이 조용히 알리고, 별과 단추는 :focus-visible 링으로 키보드 자리를 보여줍니다. 지원 범위의 근거는 caniemail 의 CSS 애니메이션 조사prefers-color-scheme 조사, 클립보드는 MDN 의 Clipboard.writeText 문서, 대비 기준은 W3C 의 최소 명도 대비 해설을 따랐습니다. 마케팅 메일 소재를 더 둘러보려면 커머스 분류로, 사고 나서 오는 메일은 이메일 템플릿 디자인 9종에, 이 사이트가 코드를 어떻게 검증하는지는 소개에 적어 두었습니다.

FAQ

메일 앱에서도 이 애니메이션이 돌아가나요?

움직임은 메일 본문이 아니라 웹 미리보기의 것입니다. caniemail 실측(2026-04-07)에서 CSS 애니메이션을 온전히 지원하는 메일 환경은 24.39%, 조건부를 합쳐도 31.71% 라 메일에서는 정적 상태로 보내는 것이 정답입니다. 아홉 개 모두 루프를 뗀 정적 상태가 완성된 화면이라 그대로 메일이 되고, 랜딩·안내 페이지에서 루프를 살려 보여줍니다.

아웃룩에서는 뭐가 제일 먼저 깨지나요?

어두운 모드 지원이 가장 갈립니다. prefers-color-scheme 을 지원하는 메일 환경은 41.86% 이고, 야후메일과 AOL 은 이 쿼리를 지워버리며, 새 아웃룩 계열은 자기네 데이터 속성으로 대신한다고 caniemail 에 기록돼 있습니다. 색을 이 쿼리 하나에만 걸지 말고 밝은 쪽을 기본으로 두고 어두운 쪽을 덧붙이는 순서가 안전합니다. 08 이 그 구조입니다.

마케팅 메일과 거래 메일은 어떻게 나눠서 쓰나요?

사고 나서 오는 영수증·배송 확인·보안 알림은 이메일 템플릿 디자인 9종이, 이번 편은 팔기 위해 먼저 보내는 뉴스레터·쿠폰·리마인드가 몫입니다. 실제 캠페인에서는 01 로 담아 02 로 당기고 04 로 붙잡는 식으로 섞어 씁니다. 거래 메일은 정보가 먼저, 마케팅 메일은 한 가지 행동이 먼저라는 점이 구조를 가릅니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.