Skip to main content

Preserving Media Queries When Inlining CSS with Juice

Stop Juice from flattening or dropping @media and prefers-color-scheme rules so your responsive and dark-mode email styles survive the inline step.

You run Juice over a template, the build passes, and then every responsive breakpoint and every dark-mode rule is gone — the email renders as a rigid desktop layout on mobile and ignores prefers-color-scheme. This guide explains exactly why Juice does that and gives you the configuration that keeps @media rules in the head where they belong.

Root cause: inliners can only inline what an element can hold

An inliner's job is to copy declarations out of a <style> block and paste them onto matching elements as style="..." attributes, because Gmail (web and app) historically strips embedded styles. That copy-to-attribute step works for selectors that resolve to concrete elements: .btn { color:#fff } becomes <a class="btn" style="color:#fff">.

Inlinable versus non-inlinable rules Element-scoped declarations move to a style attribute, while media queries, pseudo-classes and keyframes have no attribute equivalent and are dropped unless preserved. An Attribute Cannot Hold a Condition Moves cleanly to style="" color, background-color, font-family padding, border, width, text-align These apply unconditionally to one element, so an attribute expresses them exactly. Has nowhere to go @media, @supports, @font-face :hover, ::before, @keyframes Each depends on state or context the element cannot carry — so they must stay in a block.
The split is mechanical: conditional CSS cannot be represented on an element, so the inliner has to leave it where it is.

A media query has nowhere to go. @media (max-width:600px) { .col { width:100% } } is conditional — it only applies at a viewport width — and an inline style attribute is unconditional. There is no element-level representation of "but only on small screens." The same is true for @media (prefers-color-scheme: dark): an inline attribute cannot express "only when the client is in dark mode."

So Juice does the only correct thing for those rules: it leaves them where they are, inside a <style> block. The breakage happens because of a second behavior — by default Juice's removeStyleTags is effectively on, and once the inlinable rules have been hoisted out, the leftover <style> containing your media queries can be removed along with it. The declarations were not "flattened" so much as stranded in a block that then gets deleted. The fix is to tell Juice to keep that block and keep the media queries inside it. This sits at the center of any CSS inliner pipeline, and getting it wrong silently kills both responsive email layouts and dark mode email CSS.

Inlined declarations versus retained media queries Source CSS splits into element-level rules that get inlined and at-rules that must stay in a retained style block. What Juice Inlines vs. What Stays in the Head Source <style> element rules + @media Inlined to style="" .btn { color:#fff } resolves to one element survives Gmail style stripping Kept in <style> @media (max-width:600px) prefers-color-scheme: dark conditional: cannot inline
Element-level declarations get hoisted to inline attributes; conditional at-rules must remain in a retained head style block.

The exact fix: Juice options that retain the style block

Three options decide whether your media queries live or die. Set all three:

const juice = require('juice');
const fs = require('fs');

const html = fs.readFileSync('./build/email.html', 'utf8');

const inlined = juice(html, {
  // Keep @media (and @supports, @font-face) rules intact inside <style>.
  // Without this, Juice may discard at-rules it cannot inline. (Gmail app, iOS Mail)
  preserveMediaQueries: true,

  // Do NOT delete the <style> block after inlining. This is the line that
  // actually saves your responsive + dark-mode rules. (Apple Mail, iOS Mail)
  removeStyleTags: false,

  // Re-apply rules from any <style> tags so element-level declarations are
  // still copied to inline attributes for Gmail/Outlook.
  applyStyleTags: true,

  // Keep @font-face for Apple Mail web-font fallbacks.
  preserveFontFaces: true,
});

fs.writeFileSync('./dist/email.html', inlined);

With removeStyleTags:false the head block stays, with preserveMediaQueries:true the at-rules inside it are not stripped, and with applyStyleTags:true the inlinable rules are still pushed to attributes for clients that need them. That combination is the whole fix for the common case.

There is a subtle ordering trap. After inlining, every element carries its declaration as a style attribute, which has very high specificity. When a media query later tries to override width on mobile, the inline attribute wins and the breakpoint does nothing. Mark the responsive and dark overrides with !important so the retained <style> block beats the inline attributes:

/* In the source <style> — these stay in the head after inlining. */
@media (max-width: 600px) {
  /* !important so this beats the inline style="" Juice wrote on .col */
  .col { width: 100% !important; display: block !important; } /* iOS Mail, Apple Mail */
}

@media (prefers-color-scheme: dark) {
  /* !important so the dark background beats the inlined light background */
  .card { background-color: #1f2330 !important; } /* Apple Mail, iOS Mail dark */
}

If you must guarantee the block is never inlined into (only kept verbatim), wrap it so Juice treats it as embed-only. Juice respects a data-embed attribute on a style tag and leaves its rules untouched:

<!-- data-embed tells Juice: keep this block verbatim, do not hoist its rules.
     Useful for the dark-mode block so its declarations never leak to inline attributes. -->
<style data-embed>
  @media (prefers-color-scheme: dark) {
    .card { background-color:#1f2330 !important; } /* Apple Mail */
  }
</style>

You can also protect the block from clients that mishandle stray styles by keeping it inside a non-MSO conditional so Outlook for Windows (Word engine) ignores it cleanly:

<!--[if !mso]><!-->
<style>
  @media only screen and (max-width:600px) { .col { width:100% !important; } }
</style>
<!--<![endif]-->

Variant: a PostCSS pre-step that preserves prefers-color-scheme

If you author with nesting, custom properties, or autoprefixing, run PostCSS before Juice rather than letting Juice parse raw authored CSS. The key is to make sure PostCSS does not discard at-rules it does not recognize and does not collapse your dark block:

const postcss = require('postcss');
const juice = require('juice');

async function build(css, html) {
  // PostCSS pre-step: expand nesting/vars but KEEP @media + prefers-color-scheme.
  // Do not enable any plugin that drops "unknown" at-rules.
  const processed = await postcss([
    require('postcss-nested'),
    require('autoprefixer'),
  ]).process(css, { from: undefined });

  // Re-embed processed CSS, THEN inline. Juice keeps the @media blocks (above config).
  const withStyle = html.replace('</head>', `<style>${processed.css}</style></head>`);
  return juice(withStyle, { preserveMediaQueries: true, removeStyleTags: false, applyStyleTags: true });
}

This keeps your @media (prefers-color-scheme: dark) rules — the ones that drive dark mode email CSS — fully intact through both tools, so the asset swaps and color overrides they hold reach Apple Mail and iOS Mail unharmed.

Variant: dedicated prefers-color-scheme blocks that survive both tools

A common refinement is to keep dark-mode rules in their own block, isolated from the responsive block, so each can be reasoned about and guarded independently. Author two distinct retained blocks rather than one combined one: PostCSS will not touch either as long as no plugin discards unknown at-rules, and Juice keeps both with removeStyleTags:false.

<head>
  <!-- Block A: responsive. Hoistable rules are still copied to attributes,
       but the @media stays for Apple Mail / iOS Mail / Samsung Email. -->
  <style>
    @media only screen and (max-width:600px) {
      .col { width:100% !important; display:block !important; } /* iOS Mail, Samsung Email */
    }
  </style>
  <!-- Block B: dark mode, embed-only. data-embed keeps it verbatim so its
       declarations never leak to inline style="" and never get beaten by them. -->
  <style data-embed>
    :root { color-scheme: light dark; } /* Apple Mail, iOS Mail honor color-scheme */
    @media (prefers-color-scheme: dark) {
      .card { background-color:#1f2330 !important; color:#f4f4f5 !important; } /* Apple Mail dark */
      .logo-light { display:none !important; }   /* swap to dark-friendly mark */
      .logo-dark  { display:inline-block !important; }
    }
  </style>
</head>

Splitting the blocks pays off in debugging: when the mobile layout breaks you inspect Block A, when dark mode breaks you inspect Block B, and a CI assertion can count each at-rule family separately. It also lets you put only Block B behind a data-embed guard while still letting Block A's hoistable helpers reach attributes.

A PostCSS pre-step that normalizes without dropping at-rules

The safest PostCSS configuration for this workflow is explicit about not discarding anything it does not recognize. Two plugins commonly cause silent loss: postcss-discard-duplicates can merge media blocks unexpectedly, and any "discard unknown" plugin will delete mso- properties and unusual at-rules. Keep the chain minimal:

const postcss = require('postcss');
const juice = require('juice');

async function buildEmail(authoredCss, html) {
  // Expand nesting + custom properties, autoprefix — and nothing destructive.
  // NO discard-comments on @media-adjacent comments, NO discard-unknown.
  const { css } = await postcss([
    require('postcss-custom-properties')({ preserve: false }), // resolve var() — Outlook has none
    require('postcss-nested'),
    require('autoprefixer'),
  ]).process(authoredCss, { from: undefined });

  const withStyle = html.replace('</head>', `<style>${css}</style></head>`);
  return juice(withStyle, {
    preserveMediaQueries: true, // keep @media + prefers-color-scheme (Apple Mail, iOS Mail)
    removeStyleTags: false,     // do not delete the retained block
    applyStyleTags: true,       // still copy hoistable rules to attributes (Gmail, Outlook)
    preserveFontFaces: true,    // keep @font-face for Apple Mail
  });
}

Resolving custom properties in the PostCSS pre-step is not optional for cross-client work: Outlook 2016-2021 (Word engine) has no var() support and discards any declaration containing one, so a background-color: var(--card) that survives into the output renders as no background at all in Outlook desktop. Resolve it to a literal before Juice ever sees it.

Pipeline integration

  1. Author CSS in a <style> in <head> (and a data-embed block for dark/responsive rules you never want hoisted).
  2. Run PostCSS first if you use nesting/variables; otherwise feed raw CSS straight to Juice.
  3. Inline with the three-option config above.
  4. Add a CI assertion that the media-query count in dist/ matches the source — the cheapest possible guard against silent stripping.
  5. Render the output in Litmus/Email on Acid to confirm mobile breakpoints and dark mode actually fire downstream.
Build order around the inline pass Compile, then inline with preservation enabled, then assert the media query is still present before the message is queued for dispatch. Assert the Query Survived, Every Build compile template style block still separate inline with preserve media rules kept in head grep the output expect @media present queue for send or fail the build A dropped query is invisible on desktop — the assertion is the only cheap place to catch it.
The preservation option is silent when it regresses, so the build asserts the query is present rather than trusting the flag.

The cheapest insurance against silent stripping is an assertion that runs in the same step as the inline. Compare at-rule counts between source and output and fail loudly:

const fs = require('fs');
const countAtRules = (s, re) => (s.match(re) || []).length;

const src = fs.readFileSync('./build/email.html', 'utf8');
const out = fs.readFileSync('./dist/email.html', 'utf8');

const mediaSrc = countAtRules(src, /@media/g);
const mediaOut = countAtRules(out, /@media/g);
const darkOut  = countAtRules(out, /prefers-color-scheme/g);

if (mediaOut < mediaSrc) {
  throw new Error(`@media stripped: source ${mediaSrc}, output ${mediaOut} — check removeStyleTags`);
}
if (darkOut < 1) {
  throw new Error('prefers-color-scheme block missing from output — dark mode will not fire');
}

Wire this assertion in directly after the inline call in your build script so a misconfiguration can never reach an ESP. The same per-client reasoning underpins the broader responsive email layouts work and the media queries for mobile email clients troubleshooting guide — both depend on the retained block surviving exactly this step.

Validation checklist

Frequently Asked Questions

Why does the inliner remove the style block at all?

Because its default assumption is that everything in the block has been moved onto elements, so the block is now redundant. That is true for element-scoped declarations and false for anything conditional, which is the entire responsive layer. The option to keep it exists precisely because the default assumption does not hold for email.

Will keeping the style block bloat the message?

Slightly, and far less than the inlining itself adds. The block holds only what could not be inlined — media queries, pseudo-classes, @font-face — which is a small fraction of the stylesheet. If the retained block is large, the usual cause is that selectors failed to match anything and nothing was inlined at all, which is a different bug.

Does the retained block need !important on its rules?

Yes, for anything overriding a property that was also inlined. The inline declaration wins on specificity regardless of source order, so a media query rule without the flag has no effect. This is why the mobile overrides in an email stylesheet are almost universally flagged.

Can I inline at build time instead of per send?

For the template skeleton, yes, and it is the biggest performance win available in a send path. The constraint is that it only holds while the elements do not vary per recipient — a conditionally rendered section that appears for some recipients must be inlined per message or it ships unstyled.

How do I know the query survived?

Assert it. Grep the composed output for @media and fail the build if it is missing, as part of the same pre-dispatch check that verifies the MSO conditionals are intact. The failure is silent otherwise: the message renders perfectly on desktop and collapses on a phone.


← Back to Inline CSS Automation