diff --git a/.changeset/local-theme-variable-overrides.md b/.changeset/local-theme-variable-overrides.md
new file mode 100644
index 0000000000..951925638a
--- /dev/null
+++ b/.changeset/local-theme-variable-overrides.md
@@ -0,0 +1,5 @@
+---
+"react-email": patch
+---
+
+Preserve custom utility theme-variable overrides when resolving email styles, including local declaration order and importance.
diff --git a/packages/react-email/src/components/tailwind/tailwind.spec.tsx b/packages/react-email/src/components/tailwind/tailwind.spec.tsx
index 5268011e8e..8439aa1d1c 100644
--- a/packages/react-email/src/components/tailwind/tailwind.spec.tsx
+++ b/packages/react-email/src/components/tailwind/tailwind.spec.tsx
@@ -1248,6 +1248,32 @@ describe('Tailwind component', () => {
});
describe('with utilities', () => {
+ it.each([
+ '--color-brand: blue;',
+ '--color-brand: blue !important; --color-brand: green;',
+ ])('uses a custom utility local theme override: %s', async (declarations) => {
+ const html = await render(
+
+
+ default
+
+
+ overridden
+
+ ,
+ );
+ const document = new DOMParser().parseFromString(html, 'text/html');
+ expect(
+ document.getElementById('theme-default')?.style.backgroundColor,
+ ).toBe('red');
+ expect(document.getElementById('local-override')?.style.color).toBe(
+ 'blue',
+ );
+ });
+
it('handles empty utilities string', async () => {
const actualOutput = await render(
diff --git a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts
index 06bea30be1..bd23a94f3f 100644
--- a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts
+++ b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts
@@ -2,6 +2,59 @@ import { generate, parse } from 'css-tree';
import { resolveAllCssVariables } from './resolve-all-css-variables.js';
describe('resolveAllCSSVariables', () => {
+ it.each([
+ ':root',
+ '*',
+ ])('prefers a local definition over an earlier %s default', (selector) => {
+ const root = parse(`${selector} { --brand: red; }
+ .card { --brand: blue; color: var(--brand); }
+ .default { color: var(--brand); }`);
+ resolveAllCssVariables(root);
+ expect(generate(root)).toContain('.card{--brand: blue;color:blue}');
+ expect(generate(root)).toContain('.default{color:red}');
+ });
+
+ it.each([
+ ['--brand: red; --brand: blue;', 'blue'],
+ ['--brand: red !important; --brand: blue;', 'red'],
+ ['--brand: red; --brand: blue !important;', 'blue'],
+ ['--brand: red !important; --brand: blue !important;', 'blue'],
+ ])('uses the winning local declaration in %s', (declarations, expected) => {
+ const root = parse(`:root { --brand: green; }
+ .card { ${declarations} color: var(--brand); }`);
+ resolveAllCssVariables(root);
+ expect(generate(root)).toContain(`color:${expected}`);
+ });
+
+ it.each([
+ '*',
+ '*, .other',
+ ':where(*)',
+ ':is(*)',
+ '.card',
+ ])('preserves an earlier important definition on %s', (selector) => {
+ const root = parse(`${selector} { --brand: red !important; }
+ .card { --brand: blue; color: var(--brand); }`);
+ resolveAllCssVariables(root);
+ expect(generate(root)).toContain('.card{--brand: blue;color:red}');
+ });
+
+ it('prefers a local declaration over an inherited important root default', () => {
+ const root = parse(`:root { --brand: red !important; }
+ .card { --brand: blue; color: var(--brand); }`);
+ resolveAllCssVariables(root);
+ expect(generate(root)).toContain('.card{--brand: blue;color:blue}');
+ });
+
+ it('prefers a local important declaration over a universal important default', () => {
+ const root = parse(`* { --brand: red !important; }
+ .card { --brand: blue !important; color: var(--brand); }`);
+ resolveAllCssVariables(root);
+ expect(generate(root)).toContain(
+ '.card{--brand: blue !important;color:blue}',
+ );
+ });
+
it('ignores @layer (properties) defined for browser compatibility', () => {
const root =
parse(`/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */
diff --git a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts
index 347d6755b1..a26e8f8cdc 100644
--- a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts
+++ b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts
@@ -26,16 +26,10 @@ export interface VariableDefinition {
definition: string;
}
-function doSelectorsIntersect(
- first: SelectorList | Raw,
- second: SelectorList | Raw,
+function hasUniversalSelector(
+ selector: SelectorList | Raw,
+ includeRoot: boolean,
): boolean {
- const firstStringified = generate(first);
- const secondStringified = generate(second);
- if (firstStringified === secondStringified) {
- return true;
- }
-
let hasSomeUniversal = false;
const walker = (
node: CssNode,
@@ -43,7 +37,11 @@ function doSelectorsIntersect(
parentList: List,
) => {
if (hasSomeUniversal) return;
- if (node.type === 'PseudoClassSelector' && node.name === 'root') {
+ if (
+ includeRoot &&
+ node.type === 'PseudoClassSelector' &&
+ node.name === 'root'
+ ) {
hasSomeUniversal = true;
}
if (
@@ -54,14 +52,19 @@ function doSelectorsIntersect(
hasSomeUniversal = true;
}
};
- walk(first, walker);
- walk(second, walker);
-
- if (hasSomeUniversal) {
- return true;
- }
+ walk(selector, walker);
+ return hasSomeUniversal;
+}
- return false;
+function doSelectorsIntersect(
+ first: SelectorList | Raw,
+ second: SelectorList | Raw,
+): boolean {
+ return (
+ generate(first) === generate(second) ||
+ hasUniversalSelector(first, true) ||
+ hasUniversalSelector(second, true)
+ );
}
export function resolveAllCssVariables(node: CssNode) {
@@ -138,14 +141,53 @@ export function resolveAllCssVariables(node: CssNode) {
},
});
+ const localDefinitions = new Map>();
+ for (const definition of variableDefinitions) {
+ const block = definition.path[0];
+ if (!block) continue;
+ let definitions = localDefinitions.get(block);
+ if (!definitions) {
+ definitions = new Map();
+ localDefinitions.set(block, definitions);
+ }
+ const previous = definitions.get(definition.variableName);
+ if (!previous?.declaration.important || definition.declaration.important) {
+ definitions.set(definition.variableName, definition);
+ }
+ }
+
for (const use of variableUses) {
let hasReplaced = false;
+ const localDefinition = use.path[0]
+ ? localDefinitions.get(use.path[0])?.get(use.variableName)
+ : undefined;
+ const useRule = use.path.find((ancestor) => ancestor.type === 'Rule');
for (const definition of variableDefinitions) {
if (use.variableName !== definition.variableName) {
continue;
}
+ if (localDefinition && definition !== localDefinition) {
+ const definitionRule = definition.path.find(
+ (ancestor) => ancestor.type === 'Rule',
+ );
+ // A root default is inherited, but an important declaration on the
+ // same selector or a bare universal selector applies directly.
+ const appliesDirectly =
+ definitionRule &&
+ useRule &&
+ (generate(definitionRule.prelude) === generate(useRule.prelude) ||
+ hasUniversalSelector(definitionRule.prelude, false));
+ if (
+ localDefinition.declaration.important ||
+ !definition.declaration.important ||
+ !appliesDirectly
+ ) {
+ continue;
+ }
+ }
+
if (
use.path[0]?.type === 'Block' &&
use.path[1]?.type === 'Atrule' &&