Testing Dark Mode Email Across Clients
Verify dark mode before sending: which clients need separate checks, a fixture that exposes every inversion failure, and the automated contrast pass that catches regressions.
Dark mode failures are invisible in every environment a developer normally works in, because the browser preview, the local capture server and the desktop client are all in light mode. This guide covers what to test, a fixture that surfaces every inversion failure at once, and the automated pass that stops regressions reaching production.
Root Cause: Three Behaviours That Need Three Tests
Testing dark mode as one thing produces misleading results, because clients implement three fundamentally different behaviours and a template can pass one while failing another.
Opt-in clients — Apple Mail and iOS Mail — apply your authored dark styles when you declare color-scheme and provide a prefers-color-scheme block. If your dark palette is wrong, it is wrong because you wrote it that way, and the failure is a design error rather than a client quirk.
Partial-inversion clients — Gmail, Samsung Email — recolour some elements and leave others, using heuristics you cannot query. The characteristic failure is a message that is half-inverted: a dark background with a light card still rendering white, or brand colours shifted enough to break contrast against text you did not expect to be adjacent.
Full-inversion clients — Outlook.com and several Android clients — repaint the whole surface and then expose hooks to correct it. The characteristic failure is a logo or an image with transparency vanishing, plus text that was dark-on-light becoming light-on-light where an explicit background was missing.
A template can satisfy the first and fail the second and third completely, which is why "we tested dark mode in Apple Mail" is not a meaningful statement about coverage.
The Fixture That Exposes Everything
A dedicated test message containing every construct that can fail is far more efficient than checking each production template. Send it once per release and read three screenshots.
The fixture needs: a transparent-background logo, an opaque photograph, a white card on a light page background, a cell with no explicit background colour, dark text on a light brand tint, light text on a dark brand fill, a bordered element, a button, and a table with alternating row colours. Each of those exercises a distinct inversion behaviour, and between them they cover essentially every failure a production template can exhibit.
<!-- Excerpt: the two constructs that fail most often, side by side. -->
<!-- 1. A cell with NO explicit background. Full-inversion clients repaint the
page behind it, and the dark text you set becomes dark-on-dark. -->
<td style="color:#334155;font-family:Arial,sans-serif;font-size:16px;">
No background declared — should become unreadable in Outlook.com dark mode.
</td>
<!-- 2. The same cell WITH an explicit background. The client has a surface to
work from, so the pairing stays legible however it is adjusted. -->
<td bgcolor="#ffffff"
style="background-color:#ffffff;color:#334155;
font-family:Arial,sans-serif;font-size:16px;">
Background declared — should stay legible everywhere.
</td>
Building the fixture so failures are expected in the first column is what makes it fast to read. A screenshot where the left column is broken and the right is fine confirms both the client behaviour and your fix in one glance; a fixture where everything should work tells you nothing when it does.
Automating the Part That Can Be Automated
The authored dark palette — the opt-in behaviour — is fully checkable without a client, because it is your own CSS. Rendering the template in a headless browser with the dark scheme emulated and running a contrast pass catches every failure in that category before a send.
// dark-contrast.mjs — render the template in both schemes and assert contrast.
import puppeteer from "puppeteer-core";
import { readFileSync } from "node:fs";
const AXE = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
export async function checkBothSchemes(html) {
const browser = await puppeteer.launch({ executablePath: process.env.CHROME });
const page = await browser.newPage();
const results = {};
for (const scheme of ["light", "dark"]) {
// Emulating the media feature is what makes the prefers-color-scheme block
// apply — without it, only the light path is ever exercised.
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: scheme }]);
await page.setContent(html, { waitUntil: "networkidle0" });
await page.evaluate(AXE);
const run = await page.evaluate(async () =>
window.axe.run(document, { runOnly: { type: "rule", values: ["color-contrast"] } }));
results[scheme] = run.violations.flatMap((v) => v.nodes.map((n) => n.target.join(" ")));
}
await browser.close();
return results; // { light: [...selectors], dark: [...selectors] }
}
What this cannot check is the inversion behaviour, because the heuristics live inside the client and no browser reproduces them. That half needs real screenshots from a client render farm, which is slower and belongs at release rather than on every commit.
Variant: Checking a Real Device Quickly
Where a render farm is not available, two device checks cover most of the risk. An iPhone with the system appearance set to dark and the message opened in iOS Mail exercises the opt-in path with your real assets. The Outlook.com web client in a browser with the account's dark theme enabled exercises full inversion, including the data-ogsc hooks.
Those two take a few minutes and catch the majority of what a farm would report. What they miss is the partial-inversion family, which varies by Android version and manufacturer — that genuinely needs breadth rather than depth.
Reading a Dark-Mode Screenshot
Screenshots from a client farm arrive as images with no annotation, and knowing what to look at turns a slow review into a fast one. Four checks, in order, catch nearly everything.
Look at the logo first. It is the most common failure and the fastest to spot: a mark that has vanished, become a grey smear, or acquired a colour that is not the brand's. If the logo is correct, the asset-swap and plating layers are working, which is a good signal about the rest of the message.
Then check every boundary between surfaces. Failures cluster where one background meets another — a white card on a light page, a footer band, a table's alternating rows. A boundary that has disappeared means one of the two surfaces was repainted and the other was not, which is the signature of a cell with no explicit background.
Then read the smallest text. Muted secondary copy is the first thing to lose contrast when a surface is darkened, because it was chosen to be low-contrast against white and has no margin left. Footer and disclaimer text is where this shows.
Finally check the buttons. A brand-filled button with white text usually survives, but a bordered or outline button can lose its border against a repainted background, leaving text floating with no affordance. This is the failure most likely to be missed, because the text is still readable and only the interactive appearance is gone.
Comparing against the light-mode screenshot of the same message makes all four faster, because the eye finds differences more reliably than it evaluates absolutes. Where a farm provides both, review them side by side rather than sequentially.
Pipeline Integration
Run the headless contrast pass on every commit that touches a template or the token set, and fail on any dark-scheme violation. Because the dark palette is defined once in tokens, a violation there is usually one fix that resolves it across every template at once.
Send the fixture through the client farm on each release rather than each commit, and keep the previous release's screenshots for comparison. A client changing its inversion behaviour between releases is a real event, and a diff against last month's screenshots is the only thing that surfaces it.
Validation and Deployment Checklist
Frequently Asked Questions
Can I test dark mode by inverting the colours in a browser?
No. A browser's forced-colours or inverted rendering is a different algorithm from any email client's, so it produces failures you do not have and misses the ones you do. Emulating prefers-color-scheme is legitimate because it exercises your own CSS; simulating inversion is not, because it is not the client's inversion.
Why does the same template look different in Gmail on Android and Gmail on the web?
They are different renderers with different dark-mode implementations — the web client is a server-side rewriter and the app is a WebView. Treating "Gmail" as one target is the same mistake as treating "Outlook" as one, and both need separate screenshots.
Should every template be checked in a client farm?
The fixture should; production templates need it only when they introduce a construct the fixture does not cover. That is the point of a fixture — it centralises the expensive check so individual templates can rely on the headless pass plus a spot check.
What if a client's inversion breaks something we cannot override?
Design around it. Where a client will repaint a surface regardless, the workable answer is to choose a design that survives the repaint — an opaque plate behind a logo, explicit backgrounds on every cell, brand colours dark enough that a shift still leaves contrast. That is the whole reason dark mode CSS is defensive rather than declarative.
Related
- Dark Mode Email CSS — the signals and overrides being tested here
- Fixing Dark-Mode Logo Inversion in Outlook — the failure the fixture's logo case exposes
- Litmus and Email on Acid Workflows — running the fixture through a client farm
- Email Accessibility Audits — the contrast rules the headless pass applies
← Back to Dark Mode Email CSS