Skip to content
Chapter 49Lesson 3

Readable in every client

How to make a React Email transactional template hold up for every recipient, the plain-text body, the accessibility checklist, and dark mode across clients you don't control.

You can now change the HTML of a template, watch it hot-reload in the preview, and send a real test to your own inbox to confirm it survived the trip. That loop is solid. But it optimized for a single render: the HTML one, the way a sighted person sees it in a modern client, the way you saw it in the preview.

That render is only one of several that ship from the same send. The person running VoiceOver hears the message read aloud and never sees a pixel of your layout. The reader on a locked-down corporate account gets the HTML stripped at the gateway and is left with text only. The Gmail-on-Android user has the operating system inverting every color, so the dark logo you designed on white turns into a photo negative. The reader at 200% zoom on a small laptop sees your 600-pixel column reflow into a shape you never looked at. None of these people see what the preview showed you, and every one of them is still on the recipient list.

So the question this lesson answers is not “how do I make the HTML render?”, since you already solved that. It is: how do you catch everyone who doesn’t see the HTML you eyeballed? The more concrete through-line, the one that keeps this from being an abstract accessibility lecture, is where in the template does each fix live? Every correctness check here lands on a specific attribute or element in welcome.tsx. The discipline is the hard part; the syntax is small.

You will leave with three things: a plain-text version of the message that reads as a coherent message on its own, an accessibility checklist applied attribute by attribute to the template you already built, and dark-mode plumbing that ships with the template instead of getting bolted on after a support ticket. None of this is new territory so much as a redirection of skills you already have. The WCAG habits from semantic HTML, namely one <h1>, descriptive link text, and sufficient contrast, are exactly the ones you built earlier. Here they get pointed at a far more constrained surface.

Every legitimate transactional email goes on the wire as a single multipart/alternative message carrying two bodies: one text/plain and one text/html. They are not two emails. They are two encodings of the same message, bundled together, and the receiving client renders whichever one it can or prefers. You met the edge of this in the first lesson, where you got the text part for free by passing the react prop. That free text part is the text/plain body of this two-body envelope.

The text body is not optional, and it helps to know who relies on it rather than just memorizing the rule. Four kinds of reader and use depend on it:

  • Screen readers and accessibility-mode clients that don’t render HTML at all read the text body.
  • Clients and corporate security policies that strip HTML for safety leave the recipient with text only.
  • Spam and content scanners read the text part to judge the message, and a missing or empty text part is itself a faint spam signal. That ties straight back to the deliverability work from the last chapter.
  • When Gmail clips an HTML message that runs past its size budget (a constraint from the first lesson, which we won’t re-derive), the text part is part of what indexers and scanners fall back to.

The part that matters most, because it determines how you actually work, is that you do not write that text body. The Resend SDK derives it automatically from the same react node you pass to send, the same render that produced the dual-format output in the first lesson. Your application code calls nothing extra. You pass the component, and both bodies come out the other side.

This has two consequences worth keeping in mind, because getting them wrong is the most common way teams break their own plain-text experience.

First, never hand-maintain a parallel text field. It is tempting to write a nice text string by hand so you control exactly what the plain-text reader sees, but don’t. It drifts from the HTML within a release or two: someone changes the verify copy in the JSX, nobody updates the hand-written string, and now your two bodies disagree about what the email even says. This is the same rule from the first lesson, “pass the node, don’t pre-render it,” extended to the text body. The generated text is downstream of the JSX, so it can never go stale.

Second, recognize the opt-out so you understand that the auto-generated text is a default you are relying on rather than magic. You opt out of it by passing text: '' or your own string to the send call. You will rarely want to. Knowing the opt-out exists is enough.

await resend.emails.send({
from,
to,
subject: 'Verify your email',
react: <WelcomeEmail firstName={firstName} verifyUrl={verifyUrl} />,
// text: 'Pass a string here only to override the auto-generated text body.',
});

When you do need the text directly, say for a unit test that asserts on it or a custom transform, the utility is toPlainText(html) , imported from react-email and run on the output of render(). It is not a plainText flag on render. You won’t reach for it on the send path; it is the helper sitting behind the preview server’s plain-text tab and behind your tests.

One React node, two bodies on the wire. The SDK generates both; the client renders whichever it can.

The diagram shows why hand-writing the text is the wrong instinct: one source produces both bodies automatically, and the client, not you, decides which one a given recipient sees.

“The text is generated for you” is true, but it is not the same as “the text is good.” A generator can only work with the structure and wording you gave it, so reading the generated text becomes an active QA habit rather than something you set and forget.

The habit is small. Open the preview server’s plain-text tab, the one named in the last lesson, and read the text body as a standalone message, not as a degraded copy of the pretty version. Read it as the message, the way a screen-reader user or a plain-text client receives it. The mindset shift that makes this worth your time is this: a bad text version is not a text-version problem. It is a bug in your HTML JSX, and you fix it there. There is no separate text file to edit. Every defect you see in the text tab traces back to a decision in welcome.tsx.

Read it against four questions, each of which points at a specific spot in the JSX.

Are the purpose and the call to action unambiguous from the text alone? Strip away the styled button and the layout: does the reader still know what this email is and what to do? If the verify action only makes sense because of a big colored button, your body copy is carrying too little. The fix is wording: strengthen the <Text> and the heading so the message stands without the chrome.

Are links labeled rather than bare? A naked https://yourapp.com/verify/abc123 sitting alone in the text is technically correct but practically worse than the same URL with context around it. The text generator places the URL right after the link’s text, so the link text needs to read sensibly beside its own URL. The fix lives on the <Link> text: “Verify your email” reads well followed by a URL, while “click here” does not.

Do decorative images stay out of the text? A purely decorative image should contribute nothing to the text body. Give it alt="", empty and explicitly set rather than omitted, and it drops out of the text cleanly. Leave the alt missing or junk and you get stray noise: the word “image,” or a filename, littering the message. The fix lives on the <Img>’s alt.

Does the section order in the text match the HTML? If the footer floats up to the top of the text version, that is almost never a text problem. It is a structural bug in the JSX, a <Container> or nesting that is out of order. The text generator walks your component tree top to bottom, so if the text order is wrong, the tree order is wrong. Fix the structure, not the text.

{/* … layout wrapper, <Head>, brand bar … */}
<Img src="https://cdn.yourapp.com/sparkle.png" alt="" width="64" height="64" />
<Heading as="h1">Welcome, Ada!</Heading>
<Text>
Confirm your email so we can finish setting up your YourApp account.
</Text>
<Button href="https://yourapp.com/verify/abc123">Verify your email</Button>
{/* … footer … */}

This is the source you author and eyeball in the preview. Two spots decide the whole text body: the alt="" on the decorative sparkle, and the <Button> text “Verify your email” sitting in front of its href.

Put the two side by side and the four questions stop being abstract. You can see where each line of text came from: the empty-alt image produced nothing, the descriptive button text produced a readable “Verify your email:” prefix, and the order matches because the tree order matched.

You open the preview’s plain-text tab and read the generated body. One line is wrong — the filename of the decorative logo leaked in above the greeting:

logo-final-v2.png
Welcome, Ada!
Confirm your email so we can finish setting up your YourApp account.
Verify your email: https://yourapp.com/verify/abc123

The logo carries no meaning — it’s pure decoration. The rest of the body reads fine. Which edit to welcome.tsx drops that stray line?

Set the logo <Img>’s alt to the empty string.
Open the generated text body and delete the logo-final-v2.png line by hand.
Give the hero illustration above the fold an empty alt so the generator emits less text.
Shrink the logo by lowering its width and height.

The accessibility checklist for transactional mail

Section titled “The accessibility checklist for transactional mail”

Email accessibility is, perhaps surprisingly, less to remember than web accessibility, but its floors are harder rather than softer. Both halves of that are true at once.

The list is shorter because the medium works against you. Email renders on tables and inline styles, with no JavaScript and almost no working ARIA. Most of the ARIA patterns you might reach for on the web simply don’t survive in a mail client, so the set of things you can do collapses to a small one. But the things on that short list are stricter and less forgiving, because the reader cannot restyle your message. On the web a low-vision user can bump the font, override your colors, or run a reader extension. In an inbox they mostly get what you sent. So the numeric floors for font size, contrast, and touch target are hard limits you meet up front, not suggestions.

The good news is that most of these are habits you already have from semantic HTML and your component work: semantics, one heading per page, descriptive links, and contrast. Here they are just re-pointed at the constrained surface. Walk the checklist; each item is a rule, a one-line reason, and the exact place it lives in welcome.tsx.

lang on <Html>. <Html lang="en"> was already in the template from the first lesson, and here is why. A screen reader picks its pronunciation rules from lang. Leave it off and the reader falls back to the system locale and may read the entire message in the wrong accent. One attribute, whole-message stakes.

<Title> in <Head>. <Title>Verify your email</Title> gets announced by assistive tech, and some clients show it in their “open in browser” view. This is new <Head> content this lesson adds to the template.

One <h1>, in logical order. Use exactly one <Heading as="h1">, and have it state the message’s purpose; subsections drop to as="h2". Putting the logo in the <h1> is the usual anti-pattern, and it is doubly bad in email: the <h1> is the very first thing a screen-reader user navigates to, so it must be the point of the message, not the brand mark. “Verify your email,” not “YourApp.”

Descriptive link text. <Link>Verify your email</Link>, never “click here” or “this link.” Screen readers let users pull up a list of every link by its text alone, with no surrounding sentence, so each link’s text has to make sense standing by itself. This is the same point the plain-text section made about labeled links; there it served the text reader, and here it is the accessibility rationale. One discipline, two payoffs.

Image alt discipline. A decorative image gets alt="", empty and explicit, never omitted. An informational image gets one descriptive sentence. A logo gets the brand name. One footgun is worth flagging: a missing alt often falls back to the image’s filename in some clients, which is both an accessibility failure and a deliverability one. This is the same rule as the plain-text section, one attribute paying off in two places again.

Color contrast at WCAG AA. That means 4.5:1 for body text and 3:1 for headings and large text. Default <Text> on a near-white background is usually fine. The failure that actually recurs is the brand color on the call-to-action button: the text on bg-brand has to clear 4.5:1 against the button’s fill. This is the moment the project’s brand / brand-foreground pair earns a real contrast check rather than a guess.

Minimum font size. Use 14px for body text and 16px on mobile. iOS Mail will auto-bump text under about 13px, but don’t rely on the bump; design to the floor. Sizes are predictable here because of the pixelBasedPreset from the first lesson, which keeps your text-* utilities in pixels rather than rems.

Touch target of at least 44×44px. A finger needs room. The <Button> component’s default padding clears 44×44 on its own; a <Link> you have hand-styled to look like a button usually does not. That is one more reason to reach for <Button> for any real call to action: the bulletproof-button point from the first lesson, now with the accessibility reason attached.

Don’t carry meaning in color alone. A red “urgent” button means nothing to someone who can’t perceive the red, because a screen reader announces the text, not the hue. The text has to carry the meaning; color only reinforces it. This is a WCAG success criterion you met with semantic HTML, unchanged here.

<Tailwind config={emailTailwindConfig}>
<Html lang="en">
<Head>
<Title>Verify your email</Title>
</Head>
<Body>
<Container>
<Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} />
<Heading as="h1" className="text-2xl font-semibold text-zinc-900">
Verify your email
</Heading>
<Text className="text-base text-zinc-700">
Confirm your email so we can finish setting up your YourApp account.
</Text>
<Button
href={verifyUrl}
className="rounded-md bg-brand px-5 py-3 text-brand-foreground"
>
Verify your email
</Button>
<Link href="https://yourapp.com/help" className="text-sm text-zinc-500">
Visit the help center
</Link>
</Container>
</Body>
</Html>
</Tailwind>

Document level. lang="en" tells a screen reader which pronunciation rules to use; drop it and the whole message may be read in the wrong accent. <Title> is announced by assistive tech and shown in some clients’ “open in browser” view. Two attributes, whole-message stakes.

<Tailwind config={emailTailwindConfig}>
<Html lang="en">
<Head>
<Title>Verify your email</Title>
</Head>
<Body>
<Container>
<Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} />
<Heading as="h1" className="text-2xl font-semibold text-zinc-900">
Verify your email
</Heading>
<Text className="text-base text-zinc-700">
Confirm your email so we can finish setting up your YourApp account.
</Text>
<Button
href={verifyUrl}
className="rounded-md bg-brand px-5 py-3 text-brand-foreground"
>
Verify your email
</Button>
<Link href="https://yourapp.com/help" className="text-sm text-zinc-500">
Visit the help center
</Link>
</Container>
</Body>
</Html>
</Tailwind>

One <h1>, stating the purpose. Use exactly one <Heading as="h1">, and have it say what the message is: “Verify your email,” not the brand name. The <h1> is the first thing a screen-reader user lands on, so putting the logo here instead of the point is the classic anti-pattern. Subsections drop to as="h2".

<Tailwind config={emailTailwindConfig}>
<Html lang="en">
<Head>
<Title>Verify your email</Title>
</Head>
<Body>
<Container>
<Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} />
<Heading as="h1" className="text-2xl font-semibold text-zinc-900">
Verify your email
</Heading>
<Text className="text-base text-zinc-700">
Confirm your email so we can finish setting up your YourApp account.
</Text>
<Button
href={verifyUrl}
className="rounded-md bg-brand px-5 py-3 text-brand-foreground"
>
Verify your email
</Button>
<Link href="https://yourapp.com/help" className="text-sm text-zinc-500">
Visit the help center
</Link>
</Container>
</Body>
</Html>
</Tailwind>

Link text that stands alone. Both the <Button> and the <Link> read as full actions, “Verify your email” and “Visit the help center,” never “click here.” Screen readers can list every link by its text with no surrounding sentence. As a bonus, the <Button>’s default padding already clears the 44×44px touch target a hand-styled <Link> would miss.

<Tailwind config={emailTailwindConfig}>
<Html lang="en">
<Head>
<Title>Verify your email</Title>
</Head>
<Body>
<Container>
<Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} />
<Heading as="h1" className="text-2xl font-semibold text-zinc-900">
Verify your email
</Heading>
<Text className="text-base text-zinc-700">
Confirm your email so we can finish setting up your YourApp account.
</Text>
<Button
href={verifyUrl}
className="rounded-md bg-brand px-5 py-3 text-brand-foreground"
>
Verify your email
</Button>
<Link href="https://yourapp.com/help" className="text-sm text-zinc-500">
Visit the help center
</Link>
</Container>
</Body>
</Html>
</Tailwind>

alt discipline. This logo carries meaning, so its alt is the brand name, "YourApp". A purely decorative image would instead get alt="", empty and explicit, never omitted. Leave alt off and many clients fall back to the filename, an accessibility and deliverability footgun.

<Tailwind config={emailTailwindConfig}>
<Html lang="en">
<Head>
<Title>Verify your email</Title>
</Head>
<Body>
<Container>
<Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} />
<Heading as="h1" className="text-2xl font-semibold text-zinc-900">
Verify your email
</Heading>
<Text className="text-base text-zinc-700">
Confirm your email so we can finish setting up your YourApp account.
</Text>
<Button
href={verifyUrl}
className="rounded-md bg-brand px-5 py-3 text-brand-foreground"
>
Verify your email
</Button>
<Link href="https://yourapp.com/help" className="text-sm text-zinc-500">
Visit the help center
</Link>
</Container>
</Body>
</Html>
</Tailwind>

The contrast check. The CTA is where contrast most often fails: text-brand-foreground on bg-brand has to clear 4.5:1. This is the moment the project’s brand / brand-foreground pair earns a real measurement, and the light template must pass on its own.

1 / 1

The walkthrough anchors this whole section, because until you see it the checklist is just words. Once every item is a concrete attribute on the file you already built, the checklist becomes something you can actually run down the next time you write a template.

Sort each concrete edit on welcome.tsx into the checklist category it belongs to. The four categories — not the eight facts — are what you carry to the next template. Drag each item into the bucket it belongs to, then press Check.

Document & language The shell of the message
Structure & links Navigation order and link text
Images alt discipline
Sizing & contrast The strict numeric floors
lang="en" on <Html>
<Title> in <Head>
as="h1" on the one heading
Descriptive <Link> text
alt="" on a decorative image
bg-brand text clears 4.5:1
16px body on mobile
44×44px CTA touch target

Designing for dark mode you can’t control

Section titled “Designing for dark mode you can’t control”

Dark mode is the hardest part of this lesson because you are not designing for one behavior but for three, and you don’t get to choose which one a given recipient’s client uses. So start with the model before any code.

A mail client does exactly one of three things with dark mode:

  • No transformation. It renders your HTML as authored and applies your own dark styles if you provided any. Apple Mail on macOS in dark mode and recent Outlook behave this way.
  • Partial inversion. It inverts light backgrounds but preserves elements that are already dark. Brand colors usually survive, while a near-white background flips to near-black. Gmail on iOS and Outlook on iOS do this.
  • Full inversion. It inverts everything. Your dark-text-on-white logo becomes a photo negative, and brand CTA colors hue-shift into something you never picked. Gmail on Android in some configurations, and some Outlook installs, do this.
The same message, three client behaviors. Full inversion is the destructive one: it runs the whole card through an invert-and-hue-rotate, so the brand logo comes back as a washed-out negative and the CTA lands on a color you never chose.

Seeing the same message break in three different ways points to the posture directly: assume some clients will invert, design defensively so inversion doesn’t break the message, and opt into your own dark styles for the clients that honor the preference.

One rule bridges back to the last section: the light template must already meet WCAG contrast entirely on its own. Dark mode is never your contrast strategy. Most clients ignore your dark preference anyway, so a message that only reaches AA contrast after a dark transform fails contrast for most of its readers. Dark mode is a courtesy layer on top of a template that already passes, not the thing that makes it pass.

With the model in place, the syntax is small. Three pieces, all living in <Head>, flag the template as dark-aware. Without them, Apple Mail won’t apply your dark styles even when the user is in dark mode, because it needs to be told the template opted in:

  • <meta name="color-scheme" content="light dark" />
  • <meta name="supported-color-schemes" content="light dark" />
  • an inline <style> with :root { color-scheme: light dark; }

These extend the same <Head> the accessibility section just added <Title> to. That leaves two ways to actually style for dark, with a recommendation between them.

The reliable approach for a transactional template is an @media (prefers-color-scheme: dark) block in an inline <style> in <Head>, targeting class or data- selectors to swap the brand color and the logo. This works across the clients that respect the preference, and it is the one to reach for.

The other is Tailwind’s dark: variant through the <Tailwind> component. It is supported but narrower: it works in Apple Mail and recent Outlook and is ignored elsewhere. It is fine for a simple background-and-text swap, but it is not a substitute for the media-query block when you need to handle real inversion. The first lesson told you not to reach for dark: yet; now you can, with the head plumbing in place, as long as you know its ceiling.

So the minimum-viable posture for the project’s transactional template is concrete: the head plumbing, plus one @media (prefers-color-scheme: dark) block that handles the brand-color swap and the logo. A full dark-everything redesign is a marketing-template concern and explicitly out of scope here.

One wrinkle is worth a line. Some Gmail clients strip <style> blocks entirely, which means your @media rules are an enhancement that may not arrive at all. That is exactly why the rule above holds: the light inline styles have to be correct on their own, because for some readers they are the only styles that survive.

<Head>
<Title>Verify your email</Title>
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<style>{`
:root { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
.cta { background-color: #1d4ed8 !important; }
.logo-light { display: none !important; }
.logo-dark { display: block !important; }
}
`}</style>
</Head>

The accessibility layer. <Title> is the single line the accessibility section added: announced by assistive tech, shown in some clients’ “open in browser” view. This is the half of the <Head> that exists for the reader, not the renderer.

<Head>
<Title>Verify your email</Title>
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<style>{`
:root { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
.cta { background-color: #1d4ed8 !important; }
.logo-light { display: none !important; }
.logo-dark { display: block !important; }
}
`}</style>
</Head>

The dark-mode layer. The two color-scheme metas flag the template as dark-aware; without them Apple Mail won’t apply your dark styles at all. The inline <style> then carries the swap: the @media (prefers-color-scheme: dark) block recolors the .cta and flips the logo. Those .cta, .logo-light, and .logo-dark selectors are plain class names you add to the elements you want swapped. <Tailwind> compiles utilities to inline styles, and an inline style can’t hold a media query, so the swap needs a real class to hook onto. Treat these <style> rules as an enhancement, because some Gmail clients strip <style>, so the light template must already be correct on its own.

1 / 1

One specific failure deserves a name even though the project won’t build the fix here. A dark-text logo on a near-white background vanishes when a client inverts to near-black: it becomes dark-on-dark, or a smeared negative. The pattern that solves it is a <picture> element with a <source media="(prefers-color-scheme: dark)"> pointing at a light-on-dark version of the logo, plus a fallback <img> with the normal dark-on-light version.

Apple Mail and most modern webmail honor it, and clients that ignore the preference still get the fallback image, so the worst case degrades to “barely visible” rather than “missing entirely.” The project ships one brand-neutral logo that reads on either background, so you won’t build the swap, but recognize the pattern when you need it.

<picture>
<source srcSet="https://cdn.yourapp.com/logo-on-dark.png" media="(prefers-color-scheme: dark)" />
<Img src="https://cdn.yourapp.com/logo-on-light.png" alt="YourApp" width="120" height="32" />
</picture>

Each claim is about how a real mail client treats dark mode — the four spots where the wrong belief costs you readers. Mark each statement True or False.

You ship a CTA styled with Tailwind’s dark: variant and nothing else in <Head>. Apple Mail in dark mode will pick up that dark styling.

False. Apple Mail applies your dark styles only after the template declares it’s dark-aware — the color-scheme and supported-color-schemes metas in <Head>. Without them, your dark: (or @media) rules are simply ignored, even with the user in dark mode. The plumbing is the opt-in; the styling rides on top of it.

Your CTA only clears 4.5:1 contrast once a client applies your dark styles. Since it reads fine in dark mode, the template is accessible enough to ship.

False. The light template has to pass WCAG contrast entirely on its own. Most clients ignore your dark preference — and some Gmail clients strip <style> blocks outright — so a message that only reaches AA after a dark transform fails contrast for the majority of its readers. Dark mode is a courtesy layer, never your contrast strategy.

You author no dark styles at all, yet a Gmail-on-Android recipient still sees your dark-on-white logo come back as a washed-out negative.

True. Full inversion runs the entire message through an invert-and-hue-rotate regardless of what you authored or declared. That’s exactly why you design defensively — assume some clients will invert — rather than assuming “no dark styles” means “no transformation.”

The template looks right when you flip the preview server’s dark toggle, so you can be confident it survives Gmail on Android.

False. The preview toggle only emulates a prefers-color-scheme preference — it renders what a preference-respecting client would show. It cannot reproduce a client’s own inversion heuristic. Only a real test send to that client verifies how Gmail Android actually mangles the message.

If you expect international readers, there is exactly one thing worth doing in the template today, and it is a single token. In <Html lang="en" dir="auto">, adding dir="auto" lets the client flip the layout to right-to-left for Arabic or Hebrew content on its own, without you maintaining a separate per-locale template. Combined with the logical-property utilities from earlier, ps-* and pe-* instead of pl-* and pr-*, which the <Tailwind> component supports, the message mirrors cleanly when the direction flips.

That is the whole takeaway. Real localization, meaning translated copy, ICU plurals, and per-locale templates, is a substantial topic the course handles later when it reaches internationalization properly. The project’s first welcome email is English-only. But dir="auto" is a one-token insurance policy an experienced engineer building a SaaS that expects international users would set on day one, so it earns its single line here. Everything past it belongs to the internationalization unit.

<Html lang="en" dir="auto">
  • Every transactional email ships as two bodies in a multipart/alternative envelope, text/html and text/plain, and the client renders whichever it can.
  • The text body is generated by the Resend SDK from the same react node you send. Never hand-write it; read it in the preview’s plain-text tab and fix any incoherence in the HTML JSX, where it actually lives.
  • The email accessibility checklist is short but its numeric floors are strict: lang on <Html>, a <Title>, exactly one <h1> stating the purpose, descriptive link text, decorative-versus-informational alt discipline, 4.5:1 / 3:1 contrast, 14px body (16px on mobile), a 44×44 CTA, and no meaning carried in color alone.
  • Dark mode has three client behaviors, no transform, partial inversion, and full inversion, and you design for all three because you don’t control which a reader gets.
  • The light template must pass WCAG contrast on its own. Dark mode is a courtesy layer behind opt-in head plumbing (color-scheme metas plus a @media (prefers-color-scheme: dark) block), never your accessibility strategy.
  • dir="auto" on <Html> is the one-line internationalization insurance; everything beyond it is a later topic.