Skip to content

Commit 2216625

Browse files
Ouranos27claude
andcommitted
fix(tailwind): downlevel CSS for email client compatibility
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 #2712 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0329727 commit 2216625

3 files changed

Lines changed: 325 additions & 1 deletion

File tree

packages/tailwind/src/tailwind.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react';
33
import type { Config } from 'tailwindcss';
44
import { useSuspensedPromise } from './hooks/use-suspended-promise';
55
import { sanitizeStyleSheet } from './sanitize-stylesheet';
6+
import { downlevelForEmailClients } from './utils/css/downlevel-for-email-clients';
67
import { extractRulesPerClass } from './utils/css/extract-rules-per-class';
78
import { getCustomProperties } from './utils/css/get-custom-properties';
89
import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules';
@@ -136,7 +137,9 @@ export function Tailwind({ children, config }: TailwindProps) {
136137

137138
const styleElement = (
138139
<style
139-
dangerouslySetInnerHTML={{ __html: generate(nonInlineStyles) }}
140+
dangerouslySetInnerHTML={{
141+
__html: downlevelForEmailClients(generate(nonInlineStyles)),
142+
}}
140143
/>
141144
);
142145

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { downlevelForEmailClients } from './downlevel-for-email-clients';
2+
3+
describe('downlevelForEmailClients', () => {
4+
describe('range syntax', () => {
5+
it('converts width>= to min-width', () => {
6+
expect(
7+
downlevelForEmailClients(
8+
'@media (width>=40rem){.sm_p-4{padding:1rem}}',
9+
),
10+
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}');
11+
});
12+
13+
it('converts width<= to max-width', () => {
14+
expect(
15+
downlevelForEmailClients(
16+
'@media (width<=40rem){.max-sm_p-4{padding:1rem}}',
17+
),
18+
).toBe('@media (max-width:40rem){.max-sm_p-4{padding:1rem}}');
19+
});
20+
21+
it('converts width< to max-width', () => {
22+
expect(
23+
downlevelForEmailClients(
24+
'.max-sm_text-red-600{@media (width<40rem){color:red!important}}',
25+
),
26+
).toBe(
27+
'@media (max-width:40rem){.max-sm_text-red-600{color:red!important}}',
28+
);
29+
});
30+
31+
it('converts width> to min-width', () => {
32+
expect(
33+
downlevelForEmailClients(
34+
'@media (width>40rem){.sm_p-4{padding:1rem}}',
35+
),
36+
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}');
37+
});
38+
39+
it('converts height range syntax', () => {
40+
expect(
41+
downlevelForEmailClients('@media (height>=600px){.tall{color:red}}'),
42+
).toBe('@media (min-height:600px){.tall{color:red}}');
43+
});
44+
45+
it('does not match partial property names', () => {
46+
// prefers-color-scheme should not be affected
47+
expect(
48+
downlevelForEmailClients(
49+
'@media (prefers-color-scheme:dark){.dark{color:white}}',
50+
),
51+
).toBe('@media (prefers-color-scheme:dark){.dark{color:white}}');
52+
});
53+
});
54+
55+
describe('unnesting', () => {
56+
it('unnests @media from inside a selector', () => {
57+
expect(
58+
downlevelForEmailClients(
59+
'.sm_bg-red-300{@media (min-width:40rem){background-color:red!important}}',
60+
),
61+
).toBe(
62+
'@media (min-width:40rem){.sm_bg-red-300{background-color:red!important}}',
63+
);
64+
});
65+
66+
it('handles combined range syntax + nesting', () => {
67+
expect(
68+
downlevelForEmailClients(
69+
'.sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}',
70+
),
71+
).toBe(
72+
'@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}',
73+
);
74+
});
75+
76+
it('handles multiple concatenated rules', () => {
77+
const input =
78+
'.sm_bg-red-300{@media (width>=40rem){background-color:red!important}}' +
79+
'.md_bg-red-400{@media (width>=48rem){background-color:blue!important}}' +
80+
'.lg_bg-red-500{@media (width>=64rem){background-color:green!important}}';
81+
82+
const expected =
83+
'@media (min-width:40rem){.sm_bg-red-300{background-color:red!important}}' +
84+
'@media (min-width:48rem){.md_bg-red-400{background-color:blue!important}}' +
85+
'@media (min-width:64rem){.lg_bg-red-500{background-color:green!important}}';
86+
87+
expect(downlevelForEmailClients(input)).toBe(expected);
88+
});
89+
90+
it('preserves dark mode media queries and unnests them', () => {
91+
expect(
92+
downlevelForEmailClients(
93+
'.dark_text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)!important}}',
94+
),
95+
).toBe(
96+
'@media (prefers-color-scheme:dark){.dark_text-white{color:rgb(255,255,255)!important}}',
97+
);
98+
});
99+
100+
it('preserves rules without nested @media', () => {
101+
expect(
102+
downlevelForEmailClients('.bg-red-300{background-color:red}'),
103+
).toBe('.bg-red-300{background-color:red}');
104+
});
105+
106+
it('preserves already top-level @media rules', () => {
107+
expect(
108+
downlevelForEmailClients(
109+
'@media (min-width:40rem){.sm_p-4{padding:1rem!important}}',
110+
),
111+
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem!important}}');
112+
});
113+
114+
it('passes through &:hover nesting unchanged', () => {
115+
// &:hover nesting is a separate problem — email clients don't support
116+
// :hover reliably anyway. This function only handles @media unnesting.
117+
const input =
118+
'.hover_bg-red-600{&:hover{@media (hover:hover){background-color:red!important}}}';
119+
expect(downlevelForEmailClients(input)).toBe(input);
120+
});
121+
122+
it('handles multiple @media nested in one selector', () => {
123+
const input =
124+
'.multi{@media (width>=40rem){color:red!important}@media (width>=48rem){color:blue!important}}';
125+
126+
const expected =
127+
'@media (min-width:40rem){.multi{color:red!important}}' +
128+
'@media (min-width:48rem){.multi{color:blue!important}}';
129+
130+
expect(downlevelForEmailClients(input)).toBe(expected);
131+
});
132+
133+
it('handles empty input', () => {
134+
expect(downlevelForEmailClients('')).toBe('');
135+
});
136+
});
137+
});
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Downlevels modern CSS features that email clients don't support:
3+
*
4+
* 1. Media Queries Level 4 range syntax → legacy min-width/max-width
5+
* `(width>=40rem)` → `(min-width:40rem)`
6+
*
7+
* 2. CSS Nesting (nested @media inside selectors) → top-level @media
8+
* `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
9+
* → `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
10+
*
11+
* Gmail, Outlook, Yahoo, and most email clients don't support either feature.
12+
* See: https://www.caniemail.com/features/css-at-media/
13+
* https://www.caniemail.com/features/css-nesting/
14+
*/
15+
16+
/**
17+
* Convert Media Queries Level 4 range syntax to legacy min-width/max-width.
18+
*
19+
* Tailwind v4 generates `@media (width>=40rem)` but email clients
20+
* only understand `@media (min-width:40rem)`.
21+
*
22+
* Note: strict `<` and `>` are approximated as `<=` and `>=` respectively.
23+
* The sub-pixel difference is irrelevant for email rendering.
24+
*/
25+
function downlevelRangeSyntax(css: string): string {
26+
// Order matters: >= and <= must be replaced before > and <
27+
return css
28+
.replace(/\(\s*width\s*>=\s*([^)]+)\)/g, '(min-width:$1)')
29+
.replace(/\(\s*width\s*<=\s*([^)]+)\)/g, '(max-width:$1)')
30+
.replace(/\(\s*width\s*>\s*([^)]+)\)/g, '(min-width:$1)')
31+
.replace(/\(\s*width\s*<\s*([^)]+)\)/g, '(max-width:$1)')
32+
.replace(/\(\s*height\s*>=\s*([^)]+)\)/g, '(min-height:$1)')
33+
.replace(/\(\s*height\s*<=\s*([^)]+)\)/g, '(max-height:$1)')
34+
.replace(/\(\s*height\s*>\s*([^)]+)\)/g, '(min-height:$1)')
35+
.replace(/\(\s*height\s*<\s*([^)]+)\)/g, '(max-height:$1)');
36+
}
37+
38+
/**
39+
* Extract a brace-balanced block starting at position `start` (which should
40+
* point to the opening `{`). Returns the index of the closing `}`.
41+
*/
42+
function findClosingBrace(css: string, start: number): number {
43+
let depth = 0;
44+
for (let i = start; i < css.length; i++) {
45+
if (css[i] === '{') depth++;
46+
else if (css[i] === '}') {
47+
depth--;
48+
if (depth === 0) return i;
49+
}
50+
}
51+
return css.length;
52+
}
53+
54+
/**
55+
* Parse a block's content into top-level segments, respecting brace nesting.
56+
* Each segment is a complete nested rule or at-rule.
57+
*/
58+
function parseBlockSegments(blockContent: string): string[] {
59+
const segments: string[] = [];
60+
let i = 0;
61+
62+
while (i < blockContent.length) {
63+
// Skip whitespace
64+
while (i < blockContent.length && /\s/.test(blockContent[i])) i++;
65+
if (i >= blockContent.length) break;
66+
67+
const segStart = i;
68+
69+
// Read until we find a '{' (start of a block) or run out
70+
while (i < blockContent.length && blockContent[i] !== '{') i++;
71+
72+
if (i >= blockContent.length) {
73+
// No block found — this is bare declarations (shouldn't happen in
74+
// the non-inlinable output, but handle gracefully)
75+
segments.push(blockContent.slice(segStart).trim());
76+
break;
77+
}
78+
79+
// Find the matching closing brace
80+
const closeIdx = findClosingBrace(blockContent, i);
81+
segments.push(blockContent.slice(segStart, closeIdx + 1).trim());
82+
i = closeIdx + 1;
83+
}
84+
85+
return segments.filter((s) => s.length > 0);
86+
}
87+
88+
/**
89+
* Unnest `@media` rules that are nested inside selectors.
90+
*
91+
* Tailwind v4 with CSS nesting generates:
92+
* `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
93+
*
94+
* Email clients need:
95+
* `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
96+
*
97+
* This handles one level of nesting (selector → @media → declarations).
98+
* Deeper nesting (e.g. `&:hover` inside `@media`) is preserved as-is
99+
* since pseudo-class support in email clients is limited regardless.
100+
*/
101+
function unnestMediaQueries(css: string): string {
102+
const result: string[] = [];
103+
let i = 0;
104+
105+
while (i < css.length) {
106+
// Skip whitespace
107+
while (i < css.length && /\s/.test(css[i])) {
108+
result.push(css[i]);
109+
i++;
110+
}
111+
112+
if (i >= css.length) break;
113+
114+
// Check if this is already a top-level at-rule (@media, @supports, etc.)
115+
if (css[i] === '@') {
116+
const atStart = i;
117+
while (i < css.length && css[i] !== '{') i++;
118+
if (i >= css.length) {
119+
result.push(css.slice(atStart));
120+
break;
121+
}
122+
const closingBrace = findClosingBrace(css, i);
123+
result.push(css.slice(atStart, closingBrace + 1));
124+
i = closingBrace + 1;
125+
continue;
126+
}
127+
128+
// This should be a selector — read until '{'
129+
const selectorStart = i;
130+
while (i < css.length && css[i] !== '{') i++;
131+
if (i >= css.length) {
132+
result.push(css.slice(selectorStart));
133+
break;
134+
}
135+
136+
const selector = css.slice(selectorStart, i).trim();
137+
const outerOpen = i;
138+
const outerClose = findClosingBrace(css, outerOpen);
139+
const blockContent = css.slice(outerOpen + 1, outerClose).trim();
140+
141+
// Parse the block into top-level segments to handle multiple nested @media
142+
const segments = parseBlockSegments(blockContent);
143+
const mediaSegments: string[] = [];
144+
const otherSegments: string[] = [];
145+
146+
for (const segment of segments) {
147+
if (/^@media\s/.test(segment)) {
148+
mediaSegments.push(segment);
149+
} else {
150+
otherSegments.push(segment);
151+
}
152+
}
153+
154+
if (mediaSegments.length > 0) {
155+
// Emit non-@media segments as a regular rule (if any)
156+
if (otherSegments.length > 0) {
157+
result.push(`${selector}{${otherSegments.join('')}}`);
158+
}
159+
160+
// Unnest each @media: wrap the selector inside the @media
161+
for (const mediaSegment of mediaSegments) {
162+
const braceIdx = mediaSegment.indexOf('{');
163+
const mediaHeader = mediaSegment.slice(0, braceIdx).trim();
164+
const mediaClose = findClosingBrace(mediaSegment, braceIdx);
165+
const mediaBody = mediaSegment.slice(braceIdx + 1, mediaClose);
166+
167+
result.push(`${mediaHeader}{${selector}{${mediaBody}}}`);
168+
}
169+
} else {
170+
// No nested @media — emit the entire rule as-is
171+
result.push(css.slice(selectorStart, outerClose + 1));
172+
}
173+
174+
i = outerClose + 1;
175+
}
176+
177+
return result.join('');
178+
}
179+
180+
export function downlevelForEmailClients(css: string): string {
181+
css = downlevelRangeSyntax(css);
182+
css = unnestMediaQueries(css);
183+
return css;
184+
}

0 commit comments

Comments
 (0)