Skip to content

fix(tailwind): downlevel CSS for email client compatibility#3086

Open
Ouranos27 wants to merge 1 commit intoresend:canaryfrom
Ouranos27:fix/downlevel-media-queries-for-email-clients
Open

fix(tailwind): downlevel CSS for email client compatibility#3086
Ouranos27 wants to merge 1 commit intoresend:canaryfrom
Ouranos27:fix/downlevel-media-queries-for-email-clients

Conversation

@Ouranos27
Copy link

@Ouranos27 Ouranos27 commented Mar 18, 2026

Problem

Tailwind CSS v4's compile().build() generates two modern CSS features that most email clients don't support:

1. Media Queries Level 4 range syntax — Gmail, Outlook, Yahoo strip these entirely:

/* Tailwind v4 output — broken in Gmail */
@media (width>=40rem) { ... }

/* What email clients need */
@media (min-width:40rem) { ... }

2. CSS Nesting — email clients don't parse nested at-rules inside selectors:

/* Tailwind v4 output — broken in Gmail */
.sm_p-4{@media (width>=40rem){padding:1rem!important}}

/* What email clients need */
@media (min-width:40rem){.sm_p-4{padding:1rem!important}}

When React Email uses the Tailwind compiler API directly, the PostCSS/Lightning CSS pipeline that normally downlevels these features doesn't run. The result: responsive breakpoints (sm:, md:, lg:, max-sm:) are silently stripped by email clients.

Solution

Adds a downlevelForEmailClients() string transform that runs on the generated non-inlinable CSS (the <style> tag content) before injection:

  1. Range syntax → legacy: (width>=40rem)(min-width:40rem), handles >=, <=, >, < for both width and height
  2. Unnest @media: moves nested @media rules to the top level, wrapping the parent selector inside

The transform is applied in tailwind.tsx after generate(nonInlineStyles), so it only affects the CSS that goes into the <style> tag — inlined styles are not affected.

What's not covered

  • &:hover nesting (e.g. .hover_bg-red{&:hover{@media (hover:hover){...}}}) — this is preserved as-is. Pseudo-class support in email clients is limited regardless, and resolving & to the parent selector is a separate concern.
  • Strict < / > are approximated as <= / >= (sub-pixel difference, irrelevant for email).

Testing

  • 15 unit tests covering range syntax conversion, unnesting, multi-@media per selector, &:hover pass-through, dark mode, and edge cases
  • Integration test snapshots in tailwind.spec.tsx will need updating (the <style> output changes from nested/range to legacy/unnested) — I couldn't run them locally since the workspace packages need a full build. Happy to update once CI runs.

Fixes #2712

References


Summary by cubic

Downlevels Tailwind v4 CSS for email clients by converting range media queries and unnesting @media rules before injecting the stylesheet. This restores responsive styles in Gmail, Outlook, and Yahoo.

  • Bug Fixes
    • Converts (width|height >=|<=|>|<) to legacy min-/max- media features.
    • Lifts nested @media blocks to top-level; applied only to the non-inlinable <style> CSS (inlined styles are unchanged).

Written for commit 2216625. Summary will update on new commits.

Tailwind CSS v4 generates modern CSS features that most email clients
don't support:

1. Media Queries Level 4 range syntax: `@media (width>=40rem)` —
   Gmail, Outlook, Yahoo strip these entirely.
2. CSS Nesting: `.class{@media (cond){decls}}` — email clients don't
   parse nested at-rules inside selectors.

This adds a `downlevelForEmailClients()` transform that runs on the
generated non-inlinable CSS before it's injected into the <style> tag:

- Converts range syntax to legacy min-width/max-width
- Unnests @media rules from inside selectors to top-level

Fixes resend#2712

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Mar 18, 2026

⚠️ No Changeset found

Latest commit: 2216625

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@vercel
Copy link
Contributor

vercel bot commented Mar 18, 2026

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

A member of the Team first needs to authorize it.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 18, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@react-email/tailwind@3086

commit: effac01

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files

Confidence score: 3/5

  • There is meaningful regression risk in packages/tailwind/src/utils/css/downlevel-for-email-clients.ts: parseBlockSegments not splitting declarations before nested @media can leave unsupported nested media untransformed for email clients.
  • Brace matching in the same file currently ignores CSS strings/comments, so quoted braces may break block boundary detection and produce malformed transformed CSS, which is user-facing output risk.
  • Given both findings are medium severity (6/10) with high confidence, this is likely fixable but not quite low-risk yet.
  • Pay close attention to packages/tailwind/src/utils/css/downlevel-for-email-clients.ts - parsing and brace-boundary logic can silently generate incorrect CSS output.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/tailwind/src/utils/css/downlevel-for-email-clients.ts">

<violation number="1" location="packages/tailwind/src/utils/css/downlevel-for-email-clients.ts:45">
P2: Brace matching ignores CSS strings/comments, so quoted braces can cause incorrect block boundaries and malformed transformed CSS.</violation>

<violation number="2" location="packages/tailwind/src/utils/css/downlevel-for-email-clients.ts:70">
P2: `parseBlockSegments` fails to split declarations before nested `@media`, causing `unnestMediaQueries` to miss and preserve unsupported nested media.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

const segStart = i;

// Read until we find a '{' (start of a block) or run out
while (i < blockContent.length && blockContent[i] !== '{') i++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: parseBlockSegments fails to split declarations before nested @media, causing unnestMediaQueries to miss and preserve unsupported nested media.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tailwind/src/utils/css/downlevel-for-email-clients.ts, line 70:

<comment>`parseBlockSegments` fails to split declarations before nested `@media`, causing `unnestMediaQueries` to miss and preserve unsupported nested media.</comment>

<file context>
@@ -0,0 +1,184 @@
+    const segStart = i;
+
+    // Read until we find a '{' (start of a block) or run out
+    while (i < blockContent.length && blockContent[i] !== '{') i++;
+
+    if (i >= blockContent.length) {
</file context>

function findClosingBrace(css: string, start: number): number {
let depth = 0;
for (let i = start; i < css.length; i++) {
if (css[i] === '{') depth++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Brace matching ignores CSS strings/comments, so quoted braces can cause incorrect block boundaries and malformed transformed CSS.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tailwind/src/utils/css/downlevel-for-email-clients.ts, line 45:

<comment>Brace matching ignores CSS strings/comments, so quoted braces can cause incorrect block boundaries and malformed transformed CSS.</comment>

<file context>
@@ -0,0 +1,184 @@
+function findClosingBrace(css: string, start: number): number {
+  let depth = 0;
+  for (let i = start; i < css.length; i++) {
+    if (css[i] === '{') depth++;
+    else if (css[i] === '}') {
+      depth--;
</file context>

@Ouranos27
Copy link
Author

Both findings are about edge cases that can't occur in this context:

Brace matching (strings/comments): The input is always the output of css-tree's generate(), which produces minified CSS with no comments or quoted braces. No need for a full CSS tokenizer here.

Declarations before nested @media: React Email's pipeline separates inlinable declarations (inlined into elements) from non-inlinable ones (kept in <style>) before this function runs. The block content only ever contains @media rules, never a mix of bare declarations and @media.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tailwind breakpoints don't work in Gmail

1 participant