Skip to content

feat(ui): preview the dark styles an email defines - #3712

Open
creativoma wants to merge 4 commits into
resend:canaryfrom
creativoma:fix/preview-native-dark-mode
Open

feat(ui): preview the dark styles an email defines#3712
creativoma wants to merge 4 commits into
resend:canaryfrom
creativoma:fix/preview-native-dark-mode

Conversation

@creativoma

@creativoma creativoma commented Aug 18, 2026

Copy link
Copy Markdown

Closes #3707. Implements the proposal in #3609.

Problem

The preview's dark mode toggle emulates forced color inversion — what Gmail and Outlook.com do to a light theme. Clients that honor prefers-color-scheme, such as Apple Mail and Outlook for Mac, instead render whatever dark theme the email authored, and that behavior had no representation in the preview.

Because the inversion walks the DOM recoloring inline styles, it never evaluates a media query. An email shipping real @media (prefers-color-scheme: dark) rules — which includes anything using Tailwind's dark: variants — could not be previewed at all.

Changes

The single toggle becomes two, so both client behaviors are reachable and each is labelled for what it does:

Control Emulates
Emulated color inversion Clients that ignore prefers-color-scheme (Gmail, Outlook.com)
Dark styles defined by the email Clients that honor it (Apple Mail, Outlook for Mac)

The existing ?dark search param keeps meaning color inversion, so links written before this change resolve the same way.

How the scheme is applied

Setting color-scheme on the frame is enough wherever the browser propagates it into the embedded document, and there the email's own rules match untouched. That propagation is Firefox 105+ and Chromium 129+ only; Safari does not ship it yet (it landed in Safari Technology Preview 242). Left at that, an email would render one way on Chrome and another on Safari.

So where the browser does not do it, forceColorScheme flips the value of every prefers-color-scheme condition, turning the queries the reader's real setting matches into the ones it doesn't. Only the leaf value changes, so the browser still evaluates the surrounding and/or/not and the comma-separated query list. Rules keep their position in the cascade, and prefers-color-scheme: light blocks are handled alongside the dark ones. Only the live CSSOM is touched, never the <style> elements' text, so the source view still shows the email as authored.

Fixes along the way

  • With dark mode off, an email's dark rules matched whenever the reader's own OS was set to dark, and the inversion emulation then recolored an already-dark theme. Both are now decided by the preview rather than by the machine it runs on.
  • Undoing the inversion left a white background on an email that never painted one. The inversion gives <body> the colors it already renders at so it has something to invert; those were being written back as though the email had authored them.

Testing

36 unit tests covering the condition rewriting, the URL state, and syncDarkMode against a real frame.

Verified end to end in Chromium 147, Chrome 151 and WebKit 26.4, across all three modes with the OS set to light and to dark — including the non-propagating path that Safari takes today.

Out of scope

Color inversion still only recolors inline styles; rules in a <style> block are not inverted. That is unchanged behavior and separate from what this PR addresses.


Summary by cubic

Adds a native dark‑mode preview and splits the dark‑mode control into two modes so both client behaviors are covered. Previously the preview only inverted a light theme; now it can render authored dark styles, avoids inheriting the reader’s OS setting, and removes seeded colors when exiting inversion.

  • Replaces EmulatedDarkModeToggle with DarkModeToggleGroup exposing two options: “Emulated color inversion” and “Dark styles defined by the email.”
  • Adds forceColorScheme and syncDarkMode to decide which prefers-color-scheme rules apply, mirror color-scheme to the iframe root, and flip media queries where browsers don’t propagate color-scheme (e.g., Safari).
  • Sets a consistent dark backdrop for the frame so emails without their own background look the same in both dark modes.
  • Fixes inversion teardown: seeded <body> colors are dropped, preventing a white background on emails that never set one.

Rollout

  • Existing links keep working: a valueless ?dark still selects emulated inversion. Use ?dark=native for native dark styles; omit dark to turn dark mode off.

Written for commit 6fbc48f. Summary will update on new commits.

Review in cubic

`tsc --build` writes a .tsbuildinfo next to the tsconfig that produced it,
which was showing up as an untracked file under apps/web.
The preview's dark mode toggle emulated forced color inversion, the way
clients that ignore `prefers-color-scheme` recolor a light theme. Clients
that honor it instead render whatever dark theme the email authored, and
that half had no representation: an email shipping real
`@media (prefers-color-scheme: dark)` rules could not be previewed at all.

Split the control in two so both behaviors are reachable, and add
`forceColorScheme`, which decides which of the email's color scheme rules
apply rather than leaving it to the machine the preview runs on. Setting
`color-scheme` on the frame covers this wherever the browser propagates it
into the embedded document, but that is Firefox 105+ and Chromium 129+ only
— Safari does not ship it yet. Where it is missing, every
`prefers-color-scheme` condition has its value flipped, turning the queries
the reader's setting matches into the ones it doesn't. Only the leaf value
changes, so the browser still evaluates the surrounding operators and the
query list.

This also settles a case the toggle got wrong before: with dark mode off, an
email's dark rules matched whenever the reader's own OS was set to dark, and
the inversion emulation then recolored an already-dark theme.

Only the live CSSOM is touched, never the `<style>` elements' text, so the
markup shown in the source view stays the email as authored.
The color inversion only recolors values it finds inline, so it gives
`<body>` the colors it already renders at to have something to invert. Those
were recorded alongside the ones the email really declared, and so were
written back when the inversion was undone, leaving an email that never
painted a background of its own with a white one for as long as the frame
lived.

It went unnoticed while the toggle had two states, since turning dark mode
off meant a white page anyway. Under the mode that renders an email's own
dark theme it shows as a white page behind a dark email.

Mark the properties the inversion adds so undoing removes them, and keep
restoring the ones the email authored.
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

@creativoma is attempting to deploy a commit to the resend Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6fbc48f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@react-email/ui Minor
react-email Minor
@react-email/editor Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

linear-synced PR has been synced to Linear

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preview dark mode toggle only inverts inline colors, never renders actual prefers-color-scheme styles

1 participant