I can no longer count the hours Tailwind saves me. I don't really hate the state of CSS-in-JS. I just find it gets in the way of quickly saying "make this blue, put it in a flex box oriented by row, with the items centered." It's a preference thing, and my preference is towards utility class names at this point, letting React components do any reusable composition for me. It's been great, save for one quibble; email is awful. It's not really tailwind's fault, but emails are still table-based, inline styles, and generally everything awful we definitely thought we got rid of in 2002.
What follows isn't complex enough to justify an NPM module, but it is a pattern generally usable for getting Tailwind into your HTML emails. The short version is:
- A very minimal
tailwind.email.css
is generated using the tailwind CLI. It contains@tailwind utilities;
and any resets I'd like to do. Be sure you add your email templates to tailwind's config file, otherwise your styles won't be included. - We put the generated
tailwind.email.css
through a conversion ofrem
topx
because it's 2022 and outlook still does not support therem
unit - juice inlines the tailwind styles for us where possible, keeping the media queries in the head for email clients that support it
- A rehype pipeline replaces Tailwind
--tw-
variables with their resolved values
On Generating the HTML
For what I'm currently doing, emails are built using everyday React and a renderer that calls ReactDOMServer.renderToStaticMarkup(element)
. The complexities of tr
and td
nesting is easily hidden behind some common components, which I chose to name Row
and Column
respectively. They're nothing fancy and based on the reality we'll probably never get flexbox in HTML emails in my lifetime.
const tableAttributes = {
role: "presentation",
cellPadding: 0,
cellSpacing: 0,
};
interface EmailComponentProps {
className?: string;
}
export const Row: React.FC<PropsWithChildren<EmailComponentProps>> = ({
children,
className,
}) => {
return (
<table
{...tableAttributes}
{...({ border: "0" } as Record<string, string>)}
className={cx(className, "w-full")}
>
<tr>{children}</tr>
</table>
);
};
export const Column: React.FC<PropsWithChildren<EmailComponentProps>> = ({
children,
className,
}) => {
return <td className={className}>{children}</td>;
};
Ultimately though, how you generate the HTML is up to you, as long as you have it as a string.
Tailwind Configuration
To make Tailwind email friendly, we need to disable a bunch of core plugins that don't work well across email clients. A huge shoutout goes to Can I Email. Disabling these plugins is straightforward: add them to your theme's definition, and Tailwind will make sure not to generate CSS for them. Trying to use these CSS classes will just result in useless CSS classes with no style information & can be stripped later.
module.exports = {
content: [
// put your email paths here
],
theme: {
corePlugins: {
preflight: false,
backgroundOpacity: false,
borderOpacity: false,
boxShadow: false,
divideOpacity: false,
placeholderOpacity: false,
textOpacity: false,
},
},
plugins: [],
};
Converting rem
to px
I wasn't keen on building a completely custom tailwind.config.js
file, and the rem
unit is easy enough to convert from as long as you specify a base value in pixels. Tailwind uses a default base of 16px
, so we can convert all of our units via a regular expression.
const css = originalCSS.replace(
/([\d.-]+rem)/gi,
(_, value) => `${parseFloat(value.replace(/rem$/, "")) * 16}px`
);
Applying juice
Our stylesheet is now email friendly, and it's time for juice.
Given HTML, juice will inline your CSS properties into the style attribute.
Cool. That sounds exactly what we're looking to do. We'll take our HTML plus our px-friendly CSS and ask juice to merge the two together into a final output.
const juiced = juice(html, { extraCss: css });
That's mostly it. Juice takes care of the inline style tags for us where it can, defaulting to a <style>
tag in the head where it can't (ie media queries), and just generally does the right thing all the time. About the only thing it can't do is fix custom CSS variables, but there's an issue for CSS variable support with a workaround. We'll use code similar to the workaround, but with a tool that's a bit faster than cheerio.
Resolving CSS Variables
Resolving our CSS variables requires us to make a few assumptions about how email clients behave. First, if a client supports advanced CSS such as @media
queries, they also support CSS variables. That means the only CSS variables we'll need to care about are those located inline and in the body. Second, we can assume that Tailwind isn't going to generate any weird syntax. CSS variables will be in the form of --declaration: value
, and usage will be in the form of var(--declaration)
with an optional fallback.
Replacing the variables will require two passes. On the first pass, we'll capture all our CSS variables assigned to the inline style, and on the second, we'll replace our var()
statements with the resolved values. Using rehype
and rehype-rewrite
makes this a straightforward task.
const variableDefRegex = /(--[a-zA-Z0-9-_]+)\s*:\s(.+?);/g;
const variableUsageRegex = /var\((\s*--[a-zA-Z0-9-_]+\s*)(?:\)|,\s*(.*)\))/;
const hyped = rehype()
.use(rehypeRewrite, {
rewrite: (node) => {
if (node.type !== "element") {
return node;
}
const resolveVariables = (s: string): string => {
// pass 1: pull definitions
const defs = new Map<string, string>();
let withoutDefs = s.replace(
variableDefRegex,
(_, def: string, value: string) => {
defs.set(def.trim(), value.trim());
return "";
}
);
// pass 2: replace variables (maxCycles prevents Terrible Things from happening)
let maxCycles = 1000;
while (withoutDefs.match(variableUsageRegex)) {
maxCycles--;
if (maxCycles <= 0) {
throw new Error("Max Cycles for replacement exceeded");
}
withoutDefs = withoutDefs.replace(
variableUsageRegex,
(_, def: string, fallback: string) => {
const d = def.trim();
if (defs.has(d)) {
return defs.get(d) ?? "";
}
return (fallback ?? "").trim();
}
);
}
// return clean result
return withoutDefs;
};
node.properties = {
...node.properties,
style: resolveVariables(`${node.properties?.style ?? ""}`),
};
},
})
.use(stringify)
.processSync(juiced)
.toString();
Wrapping Up
At this point, I'm pretty happy with where the code is. The generated tailwind output from the email.css is easy to understand and manage since it's just vanilla tailwind. Juice is doing what it does best, and the only manual adjustments in the code are converting the rem
to px
and fixing of inline CSS variables. That's a really small amount of maintained code relative to an email specific library and build system in exchange for the full power of Tailwind. Emails still suck, but hopefully they'll suck a little less. With the Libraries in place, we put together the Taskless Usage in about 20 minutes.
And the associated code:
/* mailwindCss.ts */
import { readFile } from "node:fs/promises";
import juice from "juice";
import { rehype } from "rehype";
import rehypeRewrite from "rehype-rewrite";
import stringify from "rehype-stringify";
/** Cache of compiled tailwind files */
const twcCache = new Map<string, Promise<string>>();
/**
* Originally based on The MailChimp Reset from Fabio Carneiro, MailChimp User Experience Design
* More info and templates on Github: https://github.com/mailchimp/Email-Blueprints
* http://www.mailchimp.com & http://www.fabio-carneiro.com
* These styles are non-inline; they impact UI added by email clients
* By line:
* (1) Force Outlook to provide a "view in browser" message
* (2) Force Hotmail to display emails at full width
* (3) Force Hotmail to display normal line spacing
* (4) Prevent WebKit and Windows mobile changing default text sizes
* (5) Remove spacing between tables in Outlook 2007 and up
* (6) Remove table borders on MSO 07+ http://www.campaignmonitor.com/blog/post/3392/1px-borders-padding-on-table-cells-in-outlook-07/
* (7) Specify bicubic resampling for MSO on img objects
* (8) Media Query block - Pretty phone numbers in email: http://www.campaignmonitor.com/blog/post/3571/using-phone-numbers-in-html-email
* (9) Media Query block - same as above, but for tablet sized devices
*/
const universalStyles = /*css*/ `
#outlook a{ padding:0; }
.ReadMsgBody{ width:100%; } .ExternalClass{ width:100%; }
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }
body, table, td, p, a, li, blockquote{ -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; }
table, td{ mso-table-lspace:0pt; mso-table-rspace:0pt; }
table td {border-collapse: collapse;}
img{-ms-interpolation-mode: bicubic;}
@media only screen and (max-device-width: 480px) {
a[href^="tel"],
a[href^="sms"] {
text-decoration: default !important;
pointer-events: auto !important;
cursor: default !important;
}
}
@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) {
a[href^="tel"],
a[href^="sms"] {
text-decoration: default !important;
pointer-events: auto !important;
cursor: default !important;
}
}
`;
/**
* Inline reset styles
* These set common defaults for a consistent UI. Their comments will be automatically stripped by juice
*/
const resetStyles = /*css*/ `
/* default margin/padding, box sizing*/
* { margin: 0; padding: 0; box-sizing: border-box; }
/* height 100% all the way down */
table, tr, td {height: 100%;}
/* img behavior - bicubic ms resizing, no border, fix space gap on gmail/hotmail */
img { outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; display: block; }
a img { border: none; }
/* mso 07, 10 table spacing fix http://www.campaignmonitor.com/blog/post/3694/removing-spacing-from-around-tables-in-outlook-2007-and-2010 */
table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt;}
/* tel/sms links are unstyled by default */
a[href^="tel"], a[href^="sms"] { text-decoration: none; pointer-events: none; cursor: default; }
`;
const variableDefRegex = /(--[a-zA-Z0-9-_]+)\s*:\s(.+?);/g;
const variableUsageRegex = /var\((\s*--[a-zA-Z0-9-_]+\s*)(?:\)|,\s*(.*)\))/;
/** Juices the styles, and then resolves CSS variables and additional HTML adjustements via rehype */
const inlineStyles = (html: string, css?: string) => {
const juiced = juice(html, { extraCss: css });
const hyped = rehype()
.use(rehypeRewrite, {
rewrite: (node) => {
if (node.type !== "element") {
return node;
}
// inline styles into the <head>
if (node.tagName === "head") {
node.children = [
...node.children,
{
type: "element",
tagName: "style",
children: [{ type: "text", value: universalStyles }],
},
];
}
const resolveVariables = (s: string): string => {
// pass 1: pull definitions
const defs = new Map<string, string>();
let withoutDefs = s.replace(
variableDefRegex,
(_, def: string, value: string) => {
defs.set(def.trim(), value.trim());
return "";
}
);
// pass 2: replace variables
let maxCycles = 1000;
while (withoutDefs.match(variableUsageRegex)) {
maxCycles--;
if (maxCycles <= 0) {
throw new Error("Max Cycles for replacement exceeded");
}
withoutDefs = withoutDefs.replace(
variableUsageRegex,
(_, def: string, fallback: string) => {
const d = def.trim();
if (defs.has(d)) {
return defs.get(d) ?? "";
}
return (fallback ?? "").trim();
}
);
}
// return clean result
return withoutDefs;
};
node.properties = {
...node.properties,
style: resolveVariables(`${node.properties?.style ?? ""}`),
};
},
})
.use(stringify)
.processSync(juiced)
.toString();
return hyped;
};
interface MailwindOptions {
/** A path to your tailwind.css file, optimized for email */
tailwindCss: string;
/** The base px value for 1rem, defaults to 16px */
basePx?: number;
/** Set to `false` to disable extended resets */
reset?: boolean;
}
const mailwindCss = async (email: string, options: MailwindOptions) => {
const basePx = options?.basePx ?? 16;
// cache promise for performance in serverless environments
if (
!twcCache.has(options.tailwindCss) ||
process.env.NODE_ENV === "development"
) {
// console.log("loading styles");
const p = new Promise<string>((resolve, reject) => {
readFile(options.tailwindCss)
.then((buf) => {
const s = buf.toString();
// rem to px
const pxed = s.replace(
/([\d.-]+rem)/gi,
(_, value) => `${parseFloat(value.replace(/rem$/, "")) * basePx}px`
);
resolve(pxed);
})
.catch((reason) => reject(reason));
});
twcCache.set(options.tailwindCss, p);
}
const s = await twcCache.get(options.tailwindCss);
if (typeof s === "undefined") {
throw new Error(`Could not load tailwind css from ${options.tailwindCss}`);
}
return inlineStyles(
email,
options.reset === false ? s : [resetStyles, s].join("\n")
);
};
export default mailwindCss;