Email Design skill for Claude Code
HTML email that survives Outlook, Gmail and dark mode. Claude builds emails for the worst renderer first: role=presentation tables, inlined CSS, width attributes on every image, a hidden preheader and a VML-safe button, then layers dark-mode and mobile styles on top. A bundled stdlib linter checks the compiled HTML for external CSS, flex layouts, missing alt or lang, and the size at which Gmail clips the footer.
- name
email-design - size240 lines · ≈3,050 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and builds HTML emails that render in Gmail, Apple Mail, Outlook (classic Word engine and new web-based), Yahoo and mobile clients — table-based fluid layouts at 600px, inlined CSS, bulletproof buttons, images-off resilience, dark mode that survives forced inversion, accessible semantics, and MJML 5 or React Email 5 workflows with a lint script for size and markup. Use when building or fixing an email template, an email looks broken in Outlook or dark mode, Gmail clips a message, or choosing between MJML, React Email and Maizzle. Try asking
- Our receipt email looks broken in Outlook for Windows, fix the template
- Build a React Email template for the weekly digest that works in Gmail dark mode
- Gmail is clipping our newsletter, find out what is bloating the HTML
Use it when
- Building or redesigning an HTML email template
- An email renders wrong in Outlook, Gmail or dark mode
- Gmail shows [Message clipped] on your emails
- Choosing between MJML, React Email and Maizzle
Skip it when
- You need sending infrastructure, SPF/DKIM or bounce handling, use transactional-email instead
- You are planning a drip sequence or writing subject lines, use email-campaigns instead
How to check the result
The lint script reports zero errors on the compiled HTML, and previews in classic Outlook, Gmail iOS dark mode and Apple Mail show the message and button intact with images on and off.
The common mistake it prevents
Putting the headline and offer inside a hero image, so the email is blank with images off and unreadable after Gmail's forced dark-mode inversion.
A finished email reads correctly with images off, in classic Outlook for Windows, in a Gmail dark-mode app, at 320px wide, and through a screen reader; its compiled HTML is under 102 KB; and every visual decision survives a client that ignores half your CSS. Design for the worst renderer first, then enhance.
Scope
This skill owns layout, visual design, markup and rendering. Sending infrastructure,
DNS and webhooks belong to transactional-email; sequence strategy, subject lines and
copy belong to email-campaigns.
Step 0 — inspect what exists
ls emails/ templates/email* src/emails 2>/dev/null # where templates live
grep -l "mjml\|@react-email\|maizzle" package.json # which toolchain
grep -rn "<mj-\|from \"@react-email" --include=*.{mjml,tsx,jsx} . | head
Find the compiled output (what actually ships). Lint and test the compiled HTML, never the source template.
Pick the toolchain
| Situation | Use |
|---|---|
| React/TS codebase, devs maintain templates, Resend or similar | React Email 5 (react-email + @react-email/components, keep versions in sync) |
| Mixed stack, designers touch templates, need proven Outlook output | MJML 5 (npx mjml in.mjml -o out.html) |
| Team already thinks in Tailwind, wants full control of HTML | Maizzle |
| One-off template in an ESP’s drag-and-drop editor | Hand-edit the ESP’s HTML; still run the lint and client tests |
| Hand-written tables from scratch | Only for a tiny template; you will reinvent MSO conditionals badly |
React Email 5 notes: render() is async (const html = await render(<Invoice {...props} />));
renderAsync was removed. Pass { plainText: true } to produce the text part. Tailwind 4
classes are supported via the Tailwind component and inlined on elements, not on custom
components — verify the compiled output, not the JSX.
MJML 5 notes: components are the same as v4 (mj-section, mj-column, mj-button,
mj-image); minification and beautification options changed across 5.x releases, so
check npx mjml --help for the installed version instead of copying v4 flags.
Hard layout rules
- Width: content container 600px (max 640px), fluid below that. Use the hybrid
pattern:
width="600"attribute plusstyle="width:100%;max-width:600px", with an MSO conditional ghost table so classic Outlook gets a fixed width. - Tables for structure.
<table role="presentation" cellpadding="0" cellspacing="0" border="0">for every layout table.<div>is fine for wrappers and text blocks, never for columns. - No flex, grid, float layouts, position, or CSS columns in structure. Classic Outlook (Word engine) ignores them.
- Columns: at most 2 on mobile-first designs; 3 only for small icon/stat rows. Stack
under 480px using
display:inline-blockcolumns withmax-width(fluid hybrid) so stacking works even where media queries are stripped. - Spacing: padding on
<td>, not margin (Outlook drops margin on many elements). Use an 8px spacing scale: 8, 16, 24, 32, 48. - CSS: inline everything that matters. Keep a
<style>block only for media queries, dark mode and client resets, under ~16 KB, and syntactically perfect — Gmail discards the whole block on one parse error. - Size: keep compiled HTML under ~90 KB; Gmail clips above ~102 KB and hides everything below the fold, including the unsubscribe link and tracking pixel.
Visual decisions that avoid the template look
Generic marketing email: logo centered, hero banner image with baked-in text, three icon columns, giant rounded button, grey footer wall. Replace with:
| Default | Do instead |
|---|---|
| Hero image with headline text inside the JPG | Live-text headline (24–32px, bold) set in HTML; image supports it, never carries it |
| Three icon+blurb columns | One clear message and one action per email; secondary items as a short linked list |
| Full-width gradient banner | Solid brand color block or a real product screenshot cropped to the relevant part |
| Centered everything | Left-aligned body text (centered only for single-line headlines and the CTA in short emails) |
| Pill button at 60px tall | Button height 44–48px, padding 12–14px 24px, radius matching the product UI (often 4–8px) |
| Footer: eight social icons | Address, why they got it, preference and unsubscribe links, at most 2 social links |
Transactional emails (receipts, resets, alerts) should look like the product UI, not like a newsletter: the key fact (amount, code, status) in the first 100px, in text large enough to read in the inbox preview pane.
Typography
- Body 16px (never under 14px), line-height 1.5, max ~70 characters per line at 600px with 24–32px side padding.
- Font stacks: web fonts load only in Apple Mail, iOS Mail and some others; Gmail and
classic Outlook ignore them. Always end with a system stack:
font-family: "Inter", -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - Classic Outlook falls back to Times New Roman when the first family is a web font
it cannot load. Wrap the web-font declaration in a
<!--[if !mso]><!-->block or set the MSO fallback via a conditional style withfont-family: Arial, sans-serif. - Use real
<h1>/<h2>/<p>with inline sizes and margins — they give screen readers structure. Reset margins explicitly (margin:0 0 16px).
Images
- Absolute
https://URLs on a domain you control or a CDN; nodata:URIs (blocked by Gmail and Outlook) and no SVG (unsupported in Gmail and Outlook) — export PNG/JPG. - Always set the
widthattribute (classic Outlook ignores CSS width on images and renders at native size), plusstyle="display:block;max-width:100%;height:auto". - Export at 2x and set the display width via attribute. Keep each image under ~200 KB and the email’s total images under ~1 MB.
- Every image gets
alt. Meaningful images: describe the content or the action. Decorative:alt="". Style alt text (style="color:#333;font:14px Arial") so images-off still looks intended. - Background images in classic Outlook need VML; if the text is readable on the
fallback
bgcolor, skip VML and accept the solid color.
Bulletproof buttons
Use an HTML button, not an image:
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td bgcolor="#1a5d3a" style="border-radius:6px;">
<a href="https://example.com/pay"
style="display:inline-block;padding:13px 24px;font:600 16px/1.2 Arial,sans-serif;
color:#ffffff;text-decoration:none;border-radius:6px;">
Pay invoice
</a>
</td>
</tr>
</table>
Classic Outlook ignores padding on <a>, so the <td> bgcolor provides the visible
area; for a fully clickable area in Outlook use a VML v:roundrect wrapped in
<!--[if mso]> (MJML’s mj-button generates an acceptable version). One primary
button per email; repeat it at most once at the bottom of long emails.
Preheader
The first text in <body> is the inbox preview. Add a hidden preheader of 40–90
characters that complements (does not repeat) the subject, then pad it with
‌ pairs so body text does not leak into the preview:
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">
Invoice #1042 for $480 is due April 3.‌ ‌ ‌
</div>
Dark mode
Three client behaviors exist:
| Behavior | Clients (verify current state on caniemail.com) | Strategy |
|---|---|---|
Honors prefers-color-scheme | Apple Mail, iOS Mail, some Outlook (macOS/new) | Write explicit dark styles in a media query |
| Partial inversion (light backgrounds darkened, dark left alone) | Outlook.com, Outlook apps | Target [data-ogsc] / [data-ogsb] selectors for fixes |
| Full forced inversion | Gmail apps (notably iOS/Android) | You cannot override; design to survive it |
Rules that survive all three:
- Add
<meta name="color-scheme" content="light dark">and<meta name="supported-color-schemes" content="light dark">, plus:root { color-scheme: light dark; }. - Never use pure
#fffffftext on pure#000000; use off-black#111111-ish backgrounds and#f2f2f2text in dark styles. - Logos: transparent PNG with a 2–3px light stroke or a pill background, or swap a dark variant via the media query. A black logo on transparent vanishes after inversion.
- Avoid text baked into images; inversion cannot fix it.
- Check brand color buttons after inversion: the client may darken the background but keep the white text — confirm contrast stays at least 4.5:1.
Accessibility checklist
-
<html lang="…">,<meta charset="utf-8">, meaningful<title> -
role="presentation"on every layout table - Logical reading order matches visual order when stacked (source order = mobile order)
- Text contrast at least 4.5:1, links distinguishable by more than color (underline in body text)
- Link text names the destination (“View invoice #1042”, not “click here”)
- Body 16px, tap targets at least 44px tall
- A plain-text part exists and is readable on its own
Workflow
- [ ] 1. Define the one job of the email and the key fact/action above the fold
- [ ] 2. Choose toolchain (table above); scaffold from an existing template if present
- [ ] 3. Build mobile layout first in source order; widen to 600px
- [ ] 4. Inline-styled typography, images with width + alt, bulletproof button
- [ ] 5. Preheader, plain-text part, dark-mode styles, footer requirements
- [ ] 6. Compile, then lint the compiled HTML
- [ ] 7. Render-test across clients; fix; re-lint
Verification
-
Lint the compiled HTML:
python3 scripts/lint_email.py dist/emails/invoice.html # exit 1 on errors python3 scripts/lint_email.py dist/emails/*.html --max-width 600It flags missing alt/lang/charset, external stylesheets, flex/grid layout divs, relative or data: image URLs, missing image widths, fixed widths over the limit, layout tables without
role="presentation", unbalanced CSS braces, a missing hidden preheader, image-heavy/text-light emails, oversized<style>, and the ~102 KB Gmail clip. Fix every ERROR; justify any WARN you keep. -
Render-test in a preview service (Litmus, Email on Acid, or the ESP’s preview) on at minimum: classic Outlook 365 for Windows, new Outlook, Gmail web, Gmail iOS (dark), Apple Mail macOS and iOS (light and dark), Yahoo, Samsung Mail if your audience skews Android.
-
Send real test emails to Gmail and Outlook.com accounts; confirm no “[Message clipped]” and correct preheader.
-
Turn images off in a client and re-read: the message and action must still work.
-
Check feature support for anything unusual on caniemail.com before relying on it.
Gotchas
- Gmail rewrites class names and strips
<style>in some contexts (for example non-Google accounts in the Gmail app); inline styles are the only guarantee. margin: 0 autocentering fails in Outlook; usealign="center"on the table cell.- Line-height on Outlook needs
mso-line-height-rule: exactlyto respect pixel values. - Outlook adds gaps under images unless they are
display:block. - iOS auto-links dates, addresses and phone numbers in blue; wrap them or add
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no">and stylea[x-apple-data-detectors]to inherit color. - ESPs inject tracking and wrappers that add KB; measure the size of what the ESP sends, not just your build output.
- Classic Outlook for Windows is being replaced by the web-based new Outlook, but enterprise audiences will run the Word engine for years — keep testing it.
Output when reporting
**Template:** invoice-ready (React Email 5 → dist/emails/invoice.html, 38 KB)
**Layout:** single column 600px fluid, stacks at 480px, 1 primary CTA
**Dark mode:** prefers-color-scheme styles + inversion-safe logo pill
**Lint:** 0 errors, 1 WARN kept (web font link — system fallback set)
**Client tests:** Outlook 365 Win, new Outlook, Gmail web/iOS dark, Apple Mail light/dark — pass Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/lint_email.py
#!/usr/bin/env python3
"""Lint a compiled HTML email for rendering, accessibility and size problems.
Usage:
python3 scripts/lint_email.py email.html [more.html ...] [--max-width 640]
Checks the *compiled* HTML (MJML / React Email / Maizzle output), not source
templates. Prints one line per finding and exits 1 if any ERROR was found.
Standard library only (Python 3.10+).
"""
from __future__ import annotations
import argparse
import re
import sys
from html.parser import HTMLParser
GMAIL_CLIP_BYTES = 102 * 1024 # Gmail clips the HTML body above ~102 KB
GMAIL_CLIP_WARN = 90 * 1024
GMAIL_STYLE_BYTES = 16 * 1024 # oversized <style> blocks are commonly dropped
VOID = {"img", "br", "hr", "meta", "link", "input", "source", "col", "area", "base", "wbr"}
BANNED_TAGS = {
"script": "ERROR", "form": "WARN", "iframe": "ERROR", "video": "WARN",
"object": "ERROR", "embed": "ERROR", "svg": "WARN", "input": "WARN", "button": "WARN",
}
HIDDEN_RE = re.compile(r"display\s*:\s*none|mso-hide\s*:\s*all|max-height\s*:\s*0|opacity\s*:\s*0", re.I)
LAYOUT_CSS_RE = re.compile(r"display\s*:\s*(flex|inline-flex|grid)|position\s*:\s*(absolute|fixed)", re.I)
WIDTH_PX_RE = re.compile(r"(?<![-\w])width\s*:\s*(\d+)px", re.I)
class EmailLinter(HTMLParser):
def __init__(self, max_width: int) -> None:
super().__init__(convert_charrefs=True)
self.max_width = max_width
self.findings: list[tuple[str, int, str]] = []
self.stack: list[str] = []
self.has_doctype = False
self.html_lang = False
self.meta_charset = False
self.meta_viewport = False
self.meta_color_scheme = False
self.in_style = False
self.style_bytes = 0
self.style_text: list[str] = []
self.body_seen = False
self.first_body_text_hidden: bool | None = None
self.hidden_depth: int | None = None
self.text_chars = 0
self.img_count = 0
self.table_count = 0
self.presentation_tables = 0
self.table_stack: list[dict] = []
self.in_title = False
self.title_text = ""
def add(self, level: str, msg: str) -> None:
self.findings.append((level, self.getpos()[0], msg))
def handle_decl(self, decl: str) -> None:
if decl.lower().startswith("doctype"):
self.has_doctype = True
def handle_starttag(self, tag: str, attrs_list) -> None:
attrs = {k.lower(): (v or "") for k, v in attrs_list}
style = attrs.get("style", "")
if tag not in VOID:
self.stack.append(tag)
if self.hidden_depth is None and HIDDEN_RE.search(style) and self.body_seen:
self.hidden_depth = len(self.stack)
if tag == "html":
self.html_lang = bool(attrs.get("lang"))
elif tag == "meta":
if "charset" in attrs or attrs.get("http-equiv", "").lower() == "content-type":
self.meta_charset = True
name = attrs.get("name", "").lower()
if name == "viewport":
self.meta_viewport = True
if name in ("color-scheme", "supported-color-schemes"):
self.meta_color_scheme = True
elif tag == "title":
self.in_title = True
elif tag == "link" and "stylesheet" in attrs.get("rel", "").lower():
href = attrs.get("href", "")
if "fonts.googleapis.com" in href or "fonts.bunny.net" in href:
self.add("INFO", "web font <link>: only some clients load it; keep a system fallback stack")
else:
self.add("ERROR", f"external stylesheet <link href='{href}'>: most clients strip it; inline the CSS")
elif tag == "style":
self.in_style = True
elif tag == "body":
self.body_seen = True
elif tag == "img":
self.img_count += 1
src = attrs.get("src", "")
if "alt" not in attrs:
self.add("ERROR", f"<img src='{src[:60]}'> has no alt attribute (use alt=\"\" for decorative images)")
if src and not src.startswith(("https://", "cid:", "data:")):
level = "ERROR" if not src.startswith("http://") else "WARN"
self.add(level, f"<img src='{src[:60]}'> is not an absolute https URL")
if src.startswith("data:"):
self.add("WARN", "data: URI image: blocked by Gmail and Outlook, and inflates size toward clipping")
if "width" not in attrs:
self.add("WARN", f"<img src='{src[:60]}'> lacks a width attribute; classic Outlook renders it at native size")
elif attrs["width"].isdigit() and int(attrs["width"]) > self.max_width:
self.add("WARN", f"<img> width={attrs['width']} exceeds {self.max_width}px")
elif tag == "table":
self.table_count += 1
role = attrs.get("role", "").lower()
if role == "presentation":
self.presentation_tables += 1
self.table_stack.append({"role": role, "has_th": False, "line": self.getpos()[0]})
w = attrs.get("width", "")
if w.isdigit() and int(w) > self.max_width:
self.add("WARN", f"<table width={w}> exceeds {self.max_width}px; stays wide on mobile and in preview panes")
elif tag == "th" and self.table_stack:
self.table_stack[-1]["has_th"] = True
elif tag == "a":
href = attrs.get("href", "").strip()
if not href or href == "#":
self.add("WARN", "<a> with empty or '#' href")
elif href.lower().startswith("javascript:"):
self.add("ERROR", "<a href='javascript:...'> is stripped by every client")
elif tag in BANNED_TAGS:
self.add(BANNED_TAGS[tag], f"<{tag}> is unsupported or stripped in major email clients")
if tag == "div" and LAYOUT_CSS_RE.search(style):
self.add("ERROR", f"<div style='{style[:50]}'> uses flex/grid/absolute positioning; classic Outlook ignores it — use tables")
elif LAYOUT_CSS_RE.search(style):
self.add("WARN", f"<{tag}> uses flex/grid/absolute positioning; check fallback in Outlook and Gmail")
for m in WIDTH_PX_RE.finditer(style):
if int(m.group(1)) > self.max_width and "max-width" not in style[: m.start()].lower()[-12:]:
self.add("WARN", f"<{tag}> inline width {m.group(1)}px exceeds {self.max_width}px")
def handle_endtag(self, tag: str) -> None:
if tag == "style":
self.in_style = False
if tag == "title":
self.in_title = False
if tag == "table" and self.table_stack:
t = self.table_stack.pop()
if t["role"] != "presentation" and not t["has_th"]:
self.findings.append(("WARN", t["line"], "layout <table> without role=\"presentation\"; screen readers announce rows and columns"))
if tag in self.stack:
while self.stack:
popped = self.stack.pop()
if self.hidden_depth is not None and len(self.stack) < self.hidden_depth:
self.hidden_depth = None
if popped == tag:
break
def handle_data(self, data: str) -> None:
if self.in_style:
self.style_bytes += len(data.encode())
self.style_text.append(data)
return
if self.in_title:
self.title_text += data
if not self.body_seen:
return
text = data.strip().replace("", "").replace(" ", "").replace("͏", "")
if text:
if self.first_body_text_hidden is None:
self.first_body_text_hidden = self.hidden_depth is not None
self.text_chars += len(text)
def lint(path: str, max_width: int) -> int:
with open(path, "rb") as fh:
raw = fh.read()
size = len(raw)
html = raw.decode("utf-8", errors="replace")
p = EmailLinter(max_width)
p.feed(html)
p.close()
if size > GMAIL_CLIP_BYTES:
p.findings.append(("ERROR", 0, f"HTML is {size/1024:.1f} KB; Gmail clips above ~102 KB (hides footer and unsubscribe)"))
elif size > GMAIL_CLIP_WARN:
p.findings.append(("WARN", 0, f"HTML is {size/1024:.1f} KB; close to Gmail's ~102 KB clipping threshold"))
if not p.has_doctype:
p.findings.append(("WARN", 0, "missing <!DOCTYPE html>; some clients fall into quirks mode"))
if not p.html_lang:
p.findings.append(("ERROR", 0, "<html> has no lang attribute; screen readers guess the language"))
if not p.meta_charset:
p.findings.append(("WARN", 0, "no <meta charset>; non-ASCII text may garble"))
if not p.meta_viewport:
p.findings.append(("WARN", 0, "no <meta name=viewport>; mobile clients may zoom out"))
if not p.meta_color_scheme:
p.findings.append(("INFO", 0, "no color-scheme meta; Apple Mail will not apply your dark-mode styles"))
if not p.title_text.strip():
p.findings.append(("WARN", 0, "empty <title>; shown by some clients and web views"))
if p.style_bytes > GMAIL_STYLE_BYTES:
p.findings.append(("WARN", 0, f"<style> totals {p.style_bytes/1024:.1f} KB; Gmail may drop oversized style blocks"))
css = "".join(p.style_text)
if css and css.count("{") != css.count("}"):
p.findings.append(("ERROR", 0, "unbalanced braces in <style>; Gmail discards the entire block on a syntax error"))
if p.first_body_text_hidden is not True:
p.findings.append(("WARN", 0, "no hidden preheader as first body text; inbox preview will show the first visible text"))
if p.img_count and p.text_chars < 200:
p.findings.append(("WARN", 0, f"only {p.text_chars} chars of live text with {p.img_count} images; image-only emails fail with images off and hurt deliverability"))
if p.table_count == 0:
p.findings.append(("WARN", 0, "no tables found; div-only layouts break in classic Outlook for Windows"))
order = {"ERROR": 0, "WARN": 1, "INFO": 2}
p.findings.sort(key=lambda f: (order[f[0]], f[1]))
print(f"== {path} ({size/1024:.1f} KB, {p.table_count} tables, {p.img_count} images)")
for level, line, msg in p.findings:
loc = f"L{line}" if line else "doc"
print(f" {level:<5} {loc:<6} {msg}")
errors = sum(1 for f in p.findings if f[0] == "ERROR")
warns = sum(1 for f in p.findings if f[0] == "WARN")
print(f" -> {errors} error(s), {warns} warning(s)")
return errors
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("files", nargs="+", help="compiled .html email files")
ap.add_argument("--max-width", type=int, default=640, help="widest allowed fixed width in px (default 640)")
args = ap.parse_args()
total = 0
for f in args.files:
try:
total += lint(f, args.max_width)
except OSError as exc:
print(f"ERROR cannot read {f}: {exc}", file=sys.stderr)
total += 1
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.