Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
8aa8f8e
feat(react-email): added a theme switcher to the dev preview (#1749)
KayleeWilliams Feb 17, 2025
2e94623
fix conflict resolution issues
gabrielmfern Oct 15, 2025
0e7ae4c
feat(demo): use Tailwind v4 on all templates (#2487)
bukinoshita Oct 16, 2025
0a7d210
fix: lockfile
gabrielmfern Oct 17, 2025
ddd37a4
feat(tailwind): update to tailwind v4 (#2425)
gabrielmfern Oct 17, 2025
19de23f
chore(demo): use local tailwind version
gabrielmfern Oct 17, 2025
9360e39
chore: remove preview version and add changeset
gabrielmfern Oct 17, 2025
26ee920
chore: update lockfile
gabrielmfern Oct 17, 2025
3c8938f
chore(root): version packages (canary) (#2578)
github-actions[bot] Oct 17, 2025
733dc39
feat(tailwind): test non-inlinable custom utilities (#2586)
gabrielmfern Oct 22, 2025
add4539
wip
gabrielmfern Oct 23, 2025
84b0727
first working version! keying the iframe with the theme
gabrielmfern Oct 23, 2025
358ac3a
add comment
gabrielmfern Oct 23, 2025
52643e5
lint
gabrielmfern Oct 23, 2025
09145b0
fix ts issue
gabrielmfern Oct 23, 2025
8783f0a
invert main body text color, and invert border, background
gabrielmfern Oct 23, 2025
4859be2
add missing darkmode applying
gabrielmfern Oct 23, 2025
42e9499
improve inversion function
gabrielmfern Oct 23, 2025
27a7100
add changeset
gabrielmfern Oct 23, 2025
829880f
Merge branch 'canary' into feat/dark-mode-switcher
gabrielmfern Oct 23, 2025
aad374b
fix acronyms
gabrielmfern Oct 23, 2025
0864ba8
add types for color
gabrielmfern Oct 23, 2025
5bc245d
add aria-labels, improve accessibility
gabrielmfern Oct 23, 2025
e01c086
preserve hash on theme change
gabrielmfern Oct 23, 2025
231ef55
improve hex regex
gabrielmfern Oct 23, 2025
3c0b355
Merge branch 'canary' into feat/dark-mode-switcher
gabrielmfern Oct 24, 2025
a6e487f
Merge branch 'canary' into feat/dark-mode-switcher
gabrielmfern Oct 24, 2025
15b4dbc
fix resizing not working
gabrielmfern Oct 24, 2025
7ae751b
improve code a bit
gabrielmfern Oct 24, 2025
e28bb90
reduce chroma by 20% when making colors dark
gabrielmfern Oct 24, 2025
fa3ff5b
avoid inversion being applied more than once, use a better color library
gabrielmfern Oct 24, 2025
cbf1c23
lint
gabrielmfern Oct 24, 2025
f151725
change the dark mode toggle group to just a toggle
gabrielmfern Oct 27, 2025
f98a84a
change moon icon
gabrielmfern Oct 27, 2025
7daccba
Revert "change moon icon"
gabrielmfern Oct 27, 2025
59accc2
Merge branch 'canary' into feat/dark-mode-switcher
gabrielmfern Oct 28, 2025
d2ebc56
lint
gabrielmfern Oct 28, 2025
88d9397
remove outdated changeset from previous implementation
gabrielmfern Oct 28, 2025
560aa62
improve accessibility for theme toggle
gabrielmfern Oct 28, 2025
52c0ca5
remove unused types
gabrielmfern Oct 28, 2025
350d9c2
Merge branch 'canary' into feat/dark-mode-switcher
gabrielmfern Oct 28, 2025
9133de4
remove workspace: prefix from preview-server dependency
gabrielmfern Oct 28, 2025
59fe52e
fix lock
gabrielmfern Oct 28, 2025
2b0bc32
don't use forward ref
gabrielmfern Oct 29, 2025
b6b6197
add workspace to tailwind dep on UI
gabrielmfern Oct 29, 2025
c7b319b
don't use lastIndex to reset the global regex
gabrielmfern Oct 29, 2025
e320a48
fix lock
gabrielmfern Oct 29, 2025
42cfd43
add support for named colors, simplify regex adding more color formats
gabrielmfern Oct 29, 2025
09c70e1
only apply color inversion on load event
gabrielmfern Oct 29, 2025
9cd92f6
improve code
gabrielmfern Oct 29, 2025
82f5d8f
fallback to the same color if color inversion throws
gabrielmfern Oct 29, 2025
300a189
add docs and make sure that color inversion is not a step function
gabrielmfern Oct 29, 2025
3a40019
fix color inversion always being applied
gabrielmfern Oct 29, 2025
93f4643
add docs for the chroma transformation
gabrielmfern Oct 29, 2025
bc8f6fd
lint
gabrielmfern Oct 29, 2025
0dfa391
Merge branch 'canary' into feat/dark-mode-switcher
gabrielmfern Oct 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/flat-masks-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-email/preview-server": minor
"react-email": minor
---

Dark mode switcher emulating email client color inversion
2 changes: 2 additions & 0 deletions packages/preview-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/tailwind": "workspace:2.0.0-canary.1",
Expand All @@ -33,6 +34,7 @@
"@types/webpack": "5.28.5",
"autoprefixer": "10.4.21",
"clsx": "2.1.1",
"colorjs.io": "0.5.2",
"esbuild": "0.25.10",
"framer-motion": "12.23.22",
"json5": "2.2.3",
Expand Down
306 changes: 306 additions & 0 deletions packages/preview-server/src/app/preview/[...slug]/email-frame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { Slot } from '@radix-ui/react-slot';
import Color from 'colorjs.io';
import type { ComponentProps } from 'react';

function* walkDom(element: Element): Generator<Element> {
if (element.children.length > 0) {
for (let i = 0; i < element.children.length; i++) {
const child = element.children.item(i)!;
yield child;
yield* walkDom(child);
}
}
}

function invertColor(colorString: string, mode: 'foreground' | 'background') {
try {
const color = new Color(colorString).to('lch');

if (mode === 'background') {
// Keeps the same lightness if it's already dark. If it's bright inverts the lightness
// - This is a characteristic from Outlook iOS
// - Parcel does something very similar
//
// The 0.75 factor ensures that, even if the lightness is 100%, the final inverted is going to be 25%
// - This is a characteristic from Apple Mail
//
// The two extra 50 terms are so that the lightness inversion doesn't become a step function
if (color.lch.l! >= 50) {
color.lch.l = 50 - (color.lch.l! - 50) * 0.75;
}
} else if (mode === 'foreground') {
// The same as what's done for background, but inverts the check for brightness.
// If the color is already bright, then it keeps the same. If the color is dark, then it inverts the brightness
if (color.lch.l! < 50) {
color.lch.l = 50 - (color.lch.l! - 50) * 0.75;
}
}

// While not exactly, I've found that email clients generally tend to reduce the chrome by 20%.
// Apple Mail specifically reduces by exactly 20%, so we're closer to Apple Mail in this sense as well.
color.lch.c! *= 0.8;

return color.toString();
} catch (exception) {
console.error(`couldn't invert color ${colorString}`, exception);
return colorString;
}
}

const colorRegex = () =>
/#[0-9a-f]{3,4}|#[0-9a-f]{6,8}|(rgb|rgba|hsl|hsv|oklab|oklch|lab|lch|hwb)\s*\(.*?\)/gi;
const namedColors = {
aliceblue: '#f0f8ff',
antiquewhite: '#faebd7',
aqua: '#00ffff',
aquamarine: '#7fffd4',
azure: '#f0ffff',
beige: '#f5f5dc',
bisque: '#ffe4c4',
black: '#000000',
blanchedalmond: '#ffebcd',
blue: '#0000ff',
blueviolet: '#8a2be2',
brown: '#a52a2a',
burlywood: '#deb887',
cadetblue: '#5f9ea0',
chartreuse: '#7fff00',
chocolate: '#d2691e',
coral: '#ff7f50',
cornflowerblue: '#6495ed',
cornsilk: '#fff8dc',
crimson: '#dc143c',
cyan: '#00ffff',
darkblue: '#00008b',
darkcyan: '#008b8b',
darkgoldenrod: '#b8860b',
darkgray: '#a9a9a9',
darkgreen: '#006400',
darkgrey: '#a9a9a9',
darkkhaki: '#bdb76b',
darkmagenta: '#8b008b',
darkolivegreen: '#556b2f',
darkorange: '#ff8c00',
darkorchid: '#9932cc',
darkred: '#8b0000',
darksalmon: '#e9967a',
darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b',
darkslategray: '#2f4f4f',
darkslategrey: '#2f4f4f',
darkturquoise: '#00ced1',
darkviolet: '#9400d3',
deeppink: '#ff1493',
deepskyblue: '#00bfff',
dimgray: '#696969',
dimgrey: '#696969',
dodgerblue: '#1e90ff',
firebrick: '#b22222',
floralwhite: '#fffaf0',
forestgreen: '#228b22',
fuchsia: '#ff00ff',
gainsboro: '#dcdcdc',
ghostwhite: '#f8f8ff',
gold: '#ffd700',
goldenrod: '#daa520',
gray: '#808080',
green: '#008000',
greenyellow: '#adff2f',
grey: '#808080',
honeydew: '#f0fff0',
hotpink: '#ff69b4',
indianred: '#cd5c5c',
indigo: '#4b0082',
ivory: '#fffff0',
khaki: '#f0e68c',
lavender: '#e6e6fa',
lavenderblush: '#fff0f5',
lawngreen: '#7cfc00',
lemonchiffon: '#fffacd',
lightblue: '#add8e6',
lightcoral: '#f08080',
lightcyan: '#e0ffff',
lightgoldenrodyellow: '#fafad2',
lightgray: '#d3d3d3',
lightgreen: '#90ee90',
lightgrey: '#d3d3d3',
lightpink: '#ffb6c1',
lightsalmon: '#ffa07a',
lightseagreen: '#20b2aa',
lightskyblue: '#87cefa',
lightslategray: '#778899',
lightslategrey: '#778899',
lightsteelblue: '#b0c4de',
lightyellow: '#ffffe0',
lime: '#00ff00',
limegreen: '#32cd32',
linen: '#faf0e6',
magenta: '#ff00ff',
maroon: '#800000',
mediumaquamarine: '#66cdaa',
mediumblue: '#0000cd',
mediumorchid: '#ba55d3',
mediumpurple: '#9370db',
mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee',
mediumspringgreen: '#00fa9a',
mediumturquoise: '#48d1cc',
mediumvioletred: '#c71585',
midnightblue: '#191970',
mintcream: '#f5fffa',
mistyrose: '#ffe4e1',
moccasin: '#ffe4b5',
navajowhite: '#ffdead',
navy: '#000080',
oldlace: '#fdf5e6',
olive: '#808000',
olivedrab: '#6b8e23',
orange: '#ffa500',
orangered: '#ff4500',
orchid: '#da70d6',
palegoldenrod: '#eee8aa',
palegreen: '#98fb98',
paleturquoise: '#afeeee',
palevioletred: '#db7093',
papayawhip: '#ffefd5',
peachpuff: '#ffdab9',
peru: '#cd853f',
pink: '#ffc0cb',
plum: '#dda0dd',
powderblue: '#b0e0e6',
purple: '#800080',
rebeccapurple: '#663399',
red: '#ff0000',
rosybrown: '#bc8f8f',
royalblue: '#4169e1',
saddlebrown: '#8b4513',
salmon: '#fa8072',
sandybrown: '#f4a460',
seagreen: '#2e8b57',
seashell: '#fff5ee',
sienna: '#a0522d',
silver: '#c0c0c0',
skyblue: '#87ceeb',
slateblue: '#6a5acd',
slategray: '#708090',
slategrey: '#708090',
snow: '#fffafa',
springgreen: '#00ff7f',
steelblue: '#4682b4',
tan: '#d2b48c',
teal: '#008080',
thistle: '#d8bfd8',
tomato: '#ff6347',
transparent: 'rgba(0,0,0,0)',
turquoise: '#40e0d0',
violet: '#ee82ee',
wheat: '#f5deb3',
white: '#ffffff',
whitesmoke: '#f5f5f5',
yellow: '#ffff00',
yellowgreen: '#9acd32',
};
const namedColorRegex = new RegExp(
`${Object.keys(namedColors).join('|')}`,
'gi',
);

function applyColorInversion(iframe: HTMLIFrameElement) {
const { contentDocument, contentWindow } = iframe;
if (!contentDocument || !contentWindow) return;

if (!contentDocument.body.style.color) {
contentDocument.body.style.color = 'rgb(0, 0, 0)';
}

for (const element of walkDom(contentDocument.documentElement)) {
if (
element instanceof
(contentWindow as unknown as typeof globalThis).HTMLElement
) {
if (element.style.color) {
element.style.color = element.style.color
.replaceAll(colorRegex(), (color) => invertColor(color, 'foreground'))
.replaceAll(namedColorRegex, (namedColor) =>
invertColor(namedColors[namedColor], 'foreground'),
);
namedColorRegex.lastIndex = 0;
}
if (element.style.background) {
element.style.background = element.style.background
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
.replaceAll(namedColorRegex, (namedColor) =>
invertColor(namedColors[namedColor], 'foreground'),
);
namedColorRegex.lastIndex = 0;
}
if (element.style.backgroundColor) {
element.style.backgroundColor = element.style.backgroundColor
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
.replaceAll(namedColorRegex, (namedColor) =>
invertColor(namedColors[namedColor], 'foreground'),
);
namedColorRegex.lastIndex = 0;
}
if (element.style.borderColor) {
element.style.borderColor = element.style.borderColor
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
.replaceAll(namedColorRegex, (namedColor) =>
invertColor(namedColors[namedColor], 'foreground'),
);
namedColorRegex.lastIndex = 0;
}
if (element.style.border) {
element.style.border = element.style.border
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
.replaceAll(namedColorRegex, (namedColor) =>
invertColor(namedColors[namedColor], 'foreground'),
);
namedColorRegex.lastIndex = 0;
}
}
}
}

interface EmailFrameProps extends ComponentProps<'iframe'> {
markup: string;
width: number;
height: number;
darkMode: boolean;
}

export function EmailFrame({
markup,
width,
height,
darkMode,
...rest
}: EmailFrameProps) {
return (
<Slot
ref={(iframe: HTMLIFrameElement) => {
if (!iframe) return;

if (darkMode) {
applyColorInversion(iframe);
}
}}
>
<iframe
srcDoc={markup}
width={width}
height={height}
onLoad={(event) => {
if (darkMode) {
const iframe = event.currentTarget;
applyColorInversion(iframe);
}
}}
{...rest}
// This key makes sure that the iframe itself remounts to the DOM when theme changes, so
// that the color changes in dark mode can be easily undone when switching to light mode.
key={darkMode ? 'iframe-inverted-colors' : 'iframe-normal-colors'}
/>
</Slot>
);
}
Loading
Loading