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>- Inlines a 4-9 KB email in under 100 µs (see Performance)
- Parses with
html5everandcssparserfrom Mozilla's Servo project - Inlines CSS from
styleandlinktags - Removes
styleandlinktags - Resolves external stylesheets, including local files
- Caches external stylesheets (optional)
- Works on Linux, Windows, and macOS
- Parses HTML5 and CSS3
- Bindings for Python, Ruby, JavaScript, Java, C, PHP, and a WebAssembly module to run in browsers.
- Elixir bindings maintained by Knock
- Command Line Interface
Try css-inline in the WebAssembly-powered playground.
Add css-inline to the dependencies in your Cargo.toml:
[dependencies]
css-inline = "0.23"Minimum Supported Rust Version: 1.85.
Cargo features:
| Feature | Default | Enables |
|---|---|---|
http |
yes | Loading remote stylesheets over http(s) (pulls in reqwest) |
file |
yes | Loading stylesheets from the local filesystem |
stylesheet-cache |
yes | StylesheetCache and the cache option (pulls in lru) |
cli |
yes | The css-inline binary (pulls in lexopt) |
async |
no | Non-blocking *_async inlining functions and StylesheetResolver::retrieve_async |
For library-only use, list the features you need:
[dependencies]
css-inline = { version = "0.23", default-features = false, features = ["http", "file", "stylesheet-cache"] }Without http and file, css-inline returns an error for any link stylesheet it cannot load.
const HTML: &str = r#"<html>
<head>
<style>h1 { color:blue; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>
</html>"#;
fn main() -> css_inline::Result<()> {
let inlined = css_inline::inline(HTML)?;
assert_eq!(
inlined,
r#"<html><head>
</head>
<body>
<h1 style="color: blue;">Big Text</h1>
</body></html>"#
);
Ok(())
}inline adds missing html and body tags, so the output is a complete HTML document.
To inline CSS into an HTML fragment, call inline_fragment with the fragment and the CSS. The output stays a fragment: inline_fragment does not add <html>, <head> or <body>, and if the input contains them, it keeps only their contents. Use inline for full documents:
const FRAGMENT: &str = r#"<main>
<h1>Hello</h1>
<section>
<p>who am i</p>
</section>
</main>"#;
const CSS: &str = r#"
p {
color: red;
}
h1 {
color: blue;
}
"#;
fn main() -> css_inline::Result<()> {
let inlined = css_inline::inline_fragment(FRAGMENT, CSS)?;
assert_eq!(
inlined,
r#"<main>
<h1 style="color: blue;">Hello</h1>
<section>
<p style="color: red;">who am i</p>
</section>
</main>"#
);
Ok(())
}inline and inline_fragment return css_inline::Result<String>, with css_inline::InlineError as the error type:
MissingStyleSheet: a local stylesheet file does not existNetwork: fetching a remote stylesheet failed (httpfeature)IO: reading a stylesheet failed, or writing to the target ofinline_to/inline_fragment_to(which write into anystd::io::Write) failedParseError: a CSS syntax error thatcss-inlinecannot skip
A stylesheet that fails to load aborts inlining with an error. Inside a style tag or stylesheet, css-inline skips rules with invalid or unsupported selectors and inlines the rest.
Configure css-inline with the builder that CSSInliner::options() returns:
const HTML: &str = "...";
fn main() -> css_inline::Result<()> {
let inliner = css_inline::CSSInliner::options()
.load_remote_stylesheets(false)
.build();
let inlined = inliner.inline(HTML)?;
// Do something with inlined HTML, e.g. send an email
Ok(())
}CSSInliner is Send + Sync: build it once and reuse it across calls and threads.
inline_style_tags. Inline CSS fromstyletags. Withfalse,css-inlineignores their CSS and still removes the tags unlesskeep_style_tagsistrue. Default:truekeep_style_tags. Keepstyletags after inlining. Default:falsekeep_link_tags. Keeplinktags after inlining. Default:falsekeep_at_rules. Keep at-rules (starting with@) after inlining. Default:falseminify_css. Remove trailing semicolons and spaces between properties and values. Default:falsebase_url. Base URL for resolving relative URLs. Use thefile://scheme to load stylesheets from the filesystem. Default:Noneload_remote_stylesheets. Load stylesheets fromlinktags, over the network or fromfile://paths. Default:truecache. Cache for external stylesheets. Default:Noneextra_css. Extra CSS to inline. Default:Nonepreallocate_node_capacity. Advanced. Number of HTML nodes to preallocate during parsing. Set it to your expected node count to avoid reallocations. Default:32remove_inlined_selectors. Remove inlined selectors from<style>blocks and keep the blocks for rules that could not be inlined (e.g.p:hover), even whenkeep_style_tagsisfalse. Default:falseapply_width_attributes. AddwidthHTML attributes from CSSwidthproperties ontable,td,thandimg. Default:falseapply_height_attributes. AddheightHTML attributes from CSSheightproperties ontable,td,thandimg. Default:falseresolve_css_variables. Replacevar()references with the values of the custom properties they refer to and drop the--*declarations. Default:false
Add data-css-inline="ignore" to a tag to skip inlining styles into it:
<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>On a link or style tag, the same attribute excludes its styles from inlining:
<head>
<!-- Styles below are ignored -->
<style data-css-inline="ignore">h1 { color:blue; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>Add data-css-inline="keep" to a style tag to keep it in the output, for example to preserve @media queries for responsive emails.
The tag stays even when keep_style_tags is false.
<head>
<!-- Styles below are not removed -->
<style data-css-inline="keep">h1 { color:blue; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>A style attribute cannot hold at-rules, so css-inline drops them by default.
Set keep_at_rules to true to inline regular rules and keep at-rules such as @media in a style tag.
That tag stays even when keep_style_tags is false.
@layer is the exception: rules inside cascade layers are inlined with layer precedence, and at-rules nested in a layer are kept wrapped in it.
<head>
<!-- With keep_at_rules=true "color:blue" will get inlined into <h1> but @media will be kept in <style> -->
<style>h1 { color: blue; } @media (max-width: 600px) { h1 { font-size: 18px; } }</style>
</head>
<body>
<h1>Big Text</h1>
</body>Set minify_css to true to drop trailing semicolons and spaces between properties and values in inlined styles:
<head>
<!-- With minify_css=true, the <h1> will have `style="color:blue;font-weight:bold"` -->
<style>h1 { color: blue; font-weight: bold; }</style>
</head>
<body>
<h1>Big Text</h1>
</body>Set resolve_css_variables to true for CSS that uses custom properties (e.g. Tailwind v4 or design tokens), which email clients do not support. var() references are replaced with the values the properties have on each element, with fallbacks for undefined or cyclic ones, and the --* declarations are dropped:
<html>
<head>
<style>
:root { --brand: #17bebb; --space: 4px; }
.cta { color: var(--brand); padding: calc(var(--space) * 2); border: var(--w, 1px) solid; }
</style>
</head>
<body>
<!-- With resolve_css_variables=true, the <a> will have
`style="color: #17bebb;padding: calc(4px * 2);border: 1px solid;"` -->
<a class="cta">Buy</a>
</body>
</html>A declaration whose var() cannot be resolved and has no fallback is dropped. Custom properties defined inside @media and other at-rules are not inlined, so they do not take part.
To load stylesheets from the filesystem, set base_url with the file:// scheme.
css-inline joins relative link hrefs onto it, so end the directory with /: with file://styles/email/, href="main.css" resolves to styles/email/main.css; without the slash, to styles/main.css.
A standard URL parser reads styles in file://styles/email/ as a host. css-inline instead treats styles/email/ as a path relative to the current directory. Use file:///abs/path/ for an absolute path.
const HTML: &str = "...";
fn main() -> css_inline::Result<()> {
let base_url = css_inline::Url::parse("file://styles/email/").expect("Invalid URL");
let inliner = css_inline::CSSInliner::options()
.base_url(Some(base_url))
.build();
let inlined = inliner.inline(HTML)?;
// Do something with inlined HTML, e.g. send an email
Ok(())
}To control how css-inline fetches external stylesheets, implement StylesheetResolver. Override retrieve, or only retrieve_from_url / retrieve_from_path. The trait's default unsupported method builds an InlineError::IO with std::io::ErrorKind::Unsupported for locations your resolver rejects:
#[derive(Debug, Default)]
pub struct CustomStylesheetResolver;
impl css_inline::StylesheetResolver for CustomStylesheetResolver {
fn retrieve(&self, location: &str) -> css_inline::Result<String> {
Err(self.unsupported("External stylesheets are not supported"))
}
}
fn main() -> css_inline::Result<()> {
let inliner = css_inline::CSSInliner::options()
.resolver(std::sync::Arc::new(CustomStylesheetResolver))
.build();
Ok(())
}Every inlining function has a non-blocking *_async counterpart behind the async feature:
css-inline = { version = "0.23", features = ["async"] }External stylesheets of a document are fetched concurrently, each URL once, and stylesheets already in the cache are not fetched again. The returned futures are Send. The default resolver reads local files on a separate thread and fetches remote ones with async reqwest (requires the http feature and a Tokio runtime):
const HTML: &str = "...";
#[cfg(feature = "async")]
#[tokio::main(flavor = "current_thread")]
async fn main() -> css_inline::Result<()> {
let inlined = css_inline::inline_async(HTML).await?;
// Or with a configured inliner
let inliner = css_inline::CSSInliner::options()
.keep_style_tags(true)
.build();
let inlined = inliner.inline_async(HTML).await?;
Ok(())
}
// This block is here for testing purposes
#[cfg(not(feature = "async"))]
fn main() -> css_inline::Result<()> {
Ok(())
}StylesheetResolver::retrieve_async calls the blocking retrieve by default, so a custom resolver that implements only retrieve blocks the executor while it runs. Override retrieve_async instead, using the re-exported css_inline::async_trait attribute. Custom resolvers work with any executor:
#[derive(Debug, Default)]
pub struct CustomStylesheetResolver;
#[cfg_attr(feature = "async", css_inline::async_trait)]
impl css_inline::StylesheetResolver for CustomStylesheetResolver {
#[cfg(feature = "async")]
async fn retrieve_async(&self, location: &str) -> css_inline::Result<String> {
// Fetch `location` with your async client
Ok(String::new())
}
}To fetch each external stylesheet once, enable the cache (requires the stylesheet-cache feature, on by default):
use std::num::NonZeroUsize;
#[cfg(feature = "stylesheet-cache")]
fn main() -> css_inline::Result<()> {
let inliner = css_inline::CSSInliner::options()
.cache(
// LRU cache keyed by resolved URL; holds up to 5 stylesheets
css_inline::StylesheetCache::new(
NonZeroUsize::new(5).expect("Invalid cache size")
)
)
.build();
Ok(())
}
// This block is here for testing purposes
#[cfg(not(feature = "stylesheet-cache"))]
fn main() -> css_inline::Result<()> {
Ok(())
}The cache option defaults to None, so css-inline fetches stylesheets on every call until you set it.
The table shows absolute timings of the Rust crate. The Python, Ruby and JavaScript READMEs compare css-inline with other inliners.
Benchmarks for css-inline==0.22.0:
| Case | Bench ID | Time | Size |
|---|---|---|---|
| Basic | simple |
4.10 µs | 230 bytes |
| Realistic-1 | big_email_1 |
74.41 µs | 8.58 KB |
| Realistic-2 | big_email_2 |
42.13 µs | 4.30 KB |
| GitHub page | big_page |
16.51 ms | 1.81 MB |
Criterion mean time, measured with rustc 1.99 on a Ryzen 9 9950X.
The harness is crates/css-inline/benches/inliner.rs (Criterion); inputs are in benchmarks/benchmarks.json. Run it with cargo bench -p css-inline.
Install with cargo:
cargo install css-inline
This command inlines CSS in two documents in parallel and writes inlined.email1.html and inlined.email2.html next to the input files:
css-inline email1.html email2.html
Piped input goes to stdout: cat email.html | css-inline > out.html.
Key flags:
--base-url: base URL for relative stylesheet links--extra-css-file <PATH>: inline extra CSS from a file (repeatable)--output-filename-prefix: output prefix instead ofinlined.
List all options with --help:
css-inline --help
Articles on how this library was built and how it works:
Ask questions in the gitter chat.
This project is licensed under the terms of the MIT license.