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">.

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.

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.

Validation checklist

  • preserveMediaQueries: true, removeStyleTags: false, and applyStyleTags: true are all set on the Juice call
  • @media rule count in dist/email.html equals the count in the source template
  • @media (prefers-color-scheme: dark) block is present in the built output, not flattened
  • Responsive and dark overrides carry !important so they beat the inline style attributes Juice wrote
  • Dark/responsive block uses data-embed (or <!--[if !mso]> guard) where it must not be hoisted or seen by Outlook desktop
  • Mobile breakpoint and dark mode both fire in an Apple Mail / iOS Mail render after the full build
  • PostCSS pre-step (if used) carries no plugin that discards unknown at-rules

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.

Deeper pipeline integration: a CI guard that fails the build

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.


← Back to Inline CSS Automation