css-inline
css-inline inlines CSS into HTML style attributes. Use it to prepare HTML emails or to embed HTML into third-party web pages.
It turns this HTML:
<html><head><style>h1 { color:blue; }</style></head><body><h1>Big Text</h1></body></html>
into:
<html><head></head><body><h1 style="color: blue;">Big Text</h1></body></html>
css-inline keeps whitespace text nodes from the input, so indented HTML stays indented in the output.
- Builds on Mozilla Servo components (
html5ever,cssparser,selectors) - Inlines CSS from
styleandlinktags - Removes
styleandlinktags - Resolves external stylesheets (including local files)
- Optionally caches external stylesheets
- Works on Linux, Windows, macOS and Android
- Supports HTML5 & CSS3
- Tested on Node.js 20 & 22
Playground
Try css-inline in the WebAssembly-powered playground.
Installation
Node.js
Install with npm:
npm i @css-inline/css-inline
The package ships TypeScript types (index.d.ts).
npm installs a prebuilt binary for your platform, so you don't need Rust. Prebuilt targets:
linux-x64-gnu, linux-x64-musl, linux-arm64-gnu, linux-arm64-musl, linux-arm-gnueabihf,
darwin-x64, darwin-arm64, win32-x64-msvc, win32-arm64-msvc, android-arm64, android-arm-eabi.
Usage
import { inline } from "@css-inline/css-inline";
// CommonJS: const { inline } = require("@css-inline/css-inline");
const inlined = inline(
"<html><head><style>h1 { color:red }</style></head><body><h1>Test</h1></body></html>",
);
// <html><head></head><body><h1 style="color: red;">Test</h1></body></html>
inline adds missing html, head and body tags, so the output is a complete HTML document.
inlineFragment(html, css, options?) inlines the css string into an HTML fragment. It never adds <html>, <head> or <body>; if the input has them, it keeps only their contents. Use inline to keep the full document structure:
import { inlineFragment } from "@css-inline/css-inline";
const inlined = inlineFragment(
"<main><h1>Hello</h1><section><p>who am i</p></section></main>",
"p { color: red; } h1 { color: blue; }",
);
// <main><h1 style="color: blue;">Hello</h1><section><p style="color: red;">who am i</p></section></main>
Configuration
Pass options as the second argument to inline or the third to inlineFragment.
inlineStyleTags. Inline CSS fromstyletags. Default:truekeepStyleTags. Keepstyletags after inlining. Default:falsekeepLinkTags. Keeplinktags after inlining. Default:falsekeepAtRules. Keep at-rules (@media,@font-face, ...) after inlining. Default:falseminifyCss. Remove trailing semicolons and spaces between properties and values. Default:falsebaseUrl. Base URL for resolving relative URLs. Use thefile://scheme to load stylesheets from the filesystem; end directory URLs with/(file:///path/to/styles/), orcss-inlineresolves relative to the parent directory. Default:nullloadRemoteStylesheets. Load stylesheets fromlinktags, over the network or fromfile://paths.falseskips everylinktag. Default:truecache. LRU cache for external stylesheets, keyed by resolved URL.sizeis the number of stylesheets to keep and must be greater than zero, e.g.{ size: 5 }. Default:nullextraCss. Extra CSS to inline. Default:nullpreallocateNodeCapacity. Advanced. Number of HTML nodes to preallocate during parsing. Set it to your expected node count to avoid reallocations. Default:32removeInlinedSelectors. Remove selectors that were inlined from<style>blocks. Default:falseapplyWidthAttributes. AddwidthHTML attributes from CSSwidthproperties ontable,td,thandimg. Default:falseapplyHeightAttributes. AddheightHTML attributes from CSSheightproperties ontable,td,thandimg. Default:falseresolveCssVariables. Replacevar()references with the values of the custom properties they refer to and drop the--*declarations. Default:false
To skip inlining for a tag, add the data-css-inline="ignore" attribute:
<html>
<head>
<style>h1 { color:blue; }</style>
</head>
<body>
<!-- The tag below won't receive additional styles -->
<h1 data-css-inline="ignore">Big Text</h1>
</body>
</html>
The same attribute on a link or style tag excludes its styles:
<head>
<!-- Styles below are ignored -->
<style data-css-inline="ignore">h1 { color:blue; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>
To keep a style tag in the output, add data-css-inline="keep". Use it to keep @media queries for responsive emails in separate style tags.
css-inline keeps such tags even when keepStyleTags is false.
<head>
<!-- Styles below are not removed -->
<style data-css-inline="keep">h1 { color:blue; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>
At-rules cannot be inlined into HTML, so css-inline removes them by default.
Set keepAtRules: true to keep them in style tags and inline the remaining styles.
css-inline keeps these tags even when keepStyleTags is false.
<head>
<!-- With keepAtRules: true, "color: blue" goes into <h1> and @media stays in <style> -->
<style>h1 { color: blue; } @media (max-width: 600px) { h1 { font-size: 18px; } }</style>
</head>
<body>
<h1>Big Text</h1>
</body>
With minifyCss: true, css-inline removes trailing semicolons and spaces between properties and values in inlined styles.
<head>
<!-- With minifyCss: true, <h1> gets style="color:blue;font-weight:bold" -->
<style>h1 { color: blue; font-weight: bold; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>
Caching is off by default. Enable it to avoid fetching the same external stylesheet on every call:
import { inline } from "@css-inline/css-inline";
const inlined = inline(
`
<html>
<head>
<link href="http://127.0.0.1:1234/external.css" rel="stylesheet">
<style>h1 { color:red }</style>
</head>
<body>
<h1>Test</h1>
</body>
</html>
`,
{ cache: { size: 5 } },
);
inline and inlineFragment are synchronous. css-inline fetches remote stylesheets with a blocking HTTP request, so the call blocks the event loop until the fetch completes.
inlineAsync and inlineFragmentAsync take the same arguments and return a Promise. They load all external stylesheets concurrently, off the event loop:
import { inlineAsync } from "@css-inline/css-inline";
const inlined = await inlineAsync(html, { baseUrl: "https://example.com/" });
Set loadRemoteStylesheets: false to skip all link tags.
Errors
inline and inlineFragment throw an Error, and inlineAsync and inlineFragmentAsync reject with the same Error:
- Invalid
baseUrl:relative URL without a base: not a url - Failed fetch:
error sending request for url (http://127.0.0.1:9/a.css): http://127.0.0.1:9/a.css - Missing local file:
Missing stylesheet file: /path/to/missing.css cache: { size: 0 }:Cache size must be an integer greater than zero
WebAssembly
@css-inline/css-inline-wasm runs css-inline in browsers. It is built with wasm-bindgen.
<iframe id="output"></iframe>
<script src="https://unpkg.com/@css-inline/css-inline-wasm"></script>
<script>
// `initWasm` is async: wait for it before calling `inline`
cssInline.initWasm(fetch('https://unpkg.com/@css-inline/css-inline-wasm/index_bg.wasm')).then(() => {
const inlinedHtml = cssInline.inline(`<html>
<head>
<style>h1 { color:blue; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>
</html>`);
document.getElementById('output').srcdoc = inlinedHtml;
});
</script>
NOTE: The WASM module loads remote stylesheets only through the async inlineAsync and inlineFragmentAsync, cannot read the filesystem, and has no cache option. See its README for npm install, Node.js setup and link tag handling. The unpkg URLs above load the latest release; pin a version (@css-inline/css-inline-wasm@0.23.2) to match your installed package.
Performance
Native css-inline runs 3.09x to 15.19x faster than juice and inline-css on the cases below. The WASM build runs 1.50x to 1.65x slower than native.
| Size | css-inline |
css-inline-wasm |
juice |
inline-css |
|
|---|---|---|---|---|---|
| Basic | 230 B | 8.14 µs | 12.23 µs (1.50x) | 40.89 µs (5.02x) | 76.10 µs (9.35x) |
| Realistic-1 | 8.58 KB | 154.56 µs | 255.62 µs (1.65x) | 478.24 µs (3.09x) | 1.00 ms (6.50x) |
| Realistic-2 | 4.30 KB | 77.86 µs | 120.31 µs (1.55x) | 571.43 µs (7.34x) | 805.15 µs (10.34x) |
| GitHub page | 1.81 MB | 20.37 ms | 30.79 ms (1.51x) | 309.51 ms (15.19x) | ERROR |
inline-css fails on the GitHub page because it cannot parse its CSS: Error: Unexpected } (line 193, char 13771).
Inputs come from benchmarks/benchmarks.json: "Basic" is simple, "Realistic-1" and "Realistic-2" are big_email_1 and big_email_2, "GitHub page" is big_page.
Ratios are relative to css-inline.
benches/bench.ts runs the suite with benny (mean time; css-inline 0.23.2, juice 11.0.3, inline-css 4.0.3), built with stable rustc 1.99 on Node.js v24.18.0, Ryzen 9 9950X.
License
Licensed under the MIT license.