Wrapping CSS-in-JS Libraries in Cascade Layers

You wrap every third-party stylesheet, order your layers carefully, and a styled component still overrides the utility that should win. The reason is not layer order — it is that the library injected its rules into a <style> element at runtime, where they are unlayered author CSS and outrank the entire stack. This page shows the three ways to fix that, extending framework integration and layer wrapping in the architecture patterns reference.

Prerequisites

A manifest with a declared slot for runtime styles, and knowing which library you are dealing with — Emotion, styled-components and the CSS-in-JS engines built on stylis share one solution; compile-time libraries such as vanilla-extract emit ordinary CSS and are wrapped like any other stylesheet.

/* WHY a named slot: without it, the wrapped output creates its own layer on
   first injection, which lands at the TOP of the stack — the same problem
   with extra steps. */
@layer reset, vendor, base, theme, components, runtime, utilities;

Step 1: Confirm the styles really are unlayered

// WHY check rather than assume: some libraries already emit layered output,
// and some builds extract to a static stylesheet you can wrap normally.
[...document.styleSheets]
  .filter((s) => !s.href)                       // injected, not linked
  .flatMap((s) => { try { return [...s.cssRules]; } catch { return []; } })
  .slice(0, 5)
  .forEach((r) => console.log(r.constructor.name, r.cssText.slice(0, 80)));

What this does: shows what the runtime is actually inserting. If nothing prints as CSSLayerBlockRule, the output is unlayered and everything below applies.

Why runtime-injected styles outrank every layer A document head containing the layered stylesheet followed by style elements injected by the CSS-in-JS runtime. The injected rules sit outside the layer system, so they win against every layer regardless of specificity. <head> link → index.css (all your layers) style[data-emotion] ← unlayered style[data-emotion] ← unlayered injected as components mount unlayered author CSS beats EVERY layer, at any specificity @layer utilities @layer components @layer theme · base · reset Raising your own layer cannot help — the winner is outside it.

Step 2: Prefer the library’s own option

Several libraries added first-class layer support, and using it is always better than a custom pipeline.

// Material UI v6+ — WHY: it emits `@layer mui` and documents the name, so
// you can position it in your manifest without reverse-engineering anything.
import { StyledEngineProvider } from '@mui/material/styles';

<StyledEngineProvider enableCssLayer>
  <App />
</StyledEngineProvider>
// styled-components v6+ — WHY: same idea, with the layer name under your
// control so it can match your manifest exactly.
import { StyleSheetManager } from 'styled-components';

<StyleSheetManager enableVendorPrefixes namespace="#root">
  <App />
</StyleSheetManager>

What this does: delegates the problem to the library, which also handles server rendering, style deduplication and hot reload — three things a hand-rolled wrapper tends to get subtly wrong.

Step 3: Add a stylis plugin when there is no native option

Emotion and styled-components both serialise through stylis, which accepts a middleware that can wrap the output.

// src/styles/layer-plugin.js
// WHY a stylis middleware: it runs on the serialised CSS text just before
// insertion, which is the only point where the whole rule is available and
// still editable.
export function cssLayerPlugin(layerName = 'runtime') {
  return (element) => {
    // Only wrap top-level rules — nesting an @layer inside an @layer block
    // would create `runtime.runtime`, which is a different layer.
    if (element.root) return;
    if (element.type === 'rule' || element.type === '@media') {
      element.return = `@layer ${layerName}{${element.value}{${
        element.children.map((c) => c.return || c.value).join('')
      }}}`;
    }
  };
}
// src/main.jsx
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import { cssLayerPlugin } from './styles/layer-plugin';

// WHY prepend: true — it inserts the cache's style elements BEFORE other
// head children, which keeps the manifest's declared order authoritative.
const cache = createCache({
  key: 'app',
  prepend: true,
  stylisPlugins: [cssLayerPlugin('runtime')],
});

export default () => (
  <CacheProvider value={cache}>
    <App />
  </CacheProvider>
);

What this does: puts every runtime-injected rule inside @layer runtime, which the manifest has already positioned between components and utilities.

Step 4: Declare the layer before the runtime injects into it

/* WHY this line must exist: the first injected rule would otherwise create
   `runtime` implicitly, at the END of the stack, above utilities — which is
   the original bug wearing a layer name. */
@layer reset, vendor, base, theme, components, runtime, utilities;

What this does: fixes the slot. This is the step most often skipped, and its absence produces a wrapper that appears to work while changing nothing about the ordering.

Three ways to layer CSS-in-JS output Three approaches compared across ordering guarantee, server-side rendering support and maintenance cost: the library's native option, a stylis middleware plugin, and post-injection CSSOM re-wrapping which is marked as a last resort. approach ordering SSR cost to maintain library option enableCssLayer guaranteed none — upstream owns it stylis middleware stylisPlugins: [...] guaranteed re-test on library upgrade CSSOM re-wrap MutationObserver racy high — last resort only Re-wrapping after injection always flashes the unlayered styles first, however briefly.

Verification

  1. Inspect a styled component. The Styles panel must show a @layer runtime annotation on its rule.
  2. Confirm a utility now beats it:
Both render paths must share one configuration Two paths into the same page. The server path renders markup with a stylesheet, and the client path hydrates and injects more. When only the client applies the layer wrapper, the styles shift at hydration; when both share a configuration, they match. server render emits the stylesheet client hydrate injects on mount one shared cache config stylisPlugins: [layer] shared — no shift at hydration verify in the raw server response client-only — styles shift the inspector hides it; view source
// Should be the utility's value, not the styled component's.
getComputedStyle(document.querySelector('.mt-0')).marginBlockStart;
  1. Check the server-rendered HTML contains the same wrapper — view source rather than the inspector, which shows the hydrated DOM:
curl -s https://localhost:3000/ | grep -o "@layer runtime" | head -1
  1. Confirm the manifest declares runtime, and that it appears in the CSSOM layer order before any component mounts.

Troubleshooting

The wrapper works in development and not in production. : The production build extracts styles to a static stylesheet through a different code path that does not run the stylis plugin. Wrap the extracted file with @import … layer(runtime) instead.

Styles shift during hydration. : The server and client used different configurations, so the server-rendered CSS is unlayered and the client-injected CSS is layered. Share one cache configuration between both entry points.

Every rule ended up in runtime.runtime. : The plugin is wrapping rules that are already inside an @layer block. Guard on element.root as in the example, or check the parent type before wrapping.

Global styles from the library are still unlayered. : createGlobalStyle and Emotion’s Global component often bypass the same serialisation path. Move genuinely global rules into your static stylesheet, where they belong anyway.

Keyframes or font-face rules disappeared. : @keyframes and @font-face inside a layer are fine, but wrapping them in a rule-shaped template is not. Restrict the plugin to rule and @media element types, as above.

Complete working example

// src/styles/emotion-cache.js — complete configuration for a layered runtime.
import createCache from '@emotion/cache';

/**
 * Wrap serialised output in a cascade layer.
 * WHY at serialisation time rather than after injection: the rule text is
 * fully formed here and nothing has been painted yet, so there is no window
 * in which the unlayered version is live.
 */
function cssLayerPlugin(layerName) {
  return (element) => {
    // WHY the guards: keyframes, font-face and imports must not be wrapped in
    // a rule-shaped block, and already-nested elements would double-nest the
    // layer into `runtime.runtime`.
    if (element.root) return;
    if (element.type !== 'rule' && element.type !== '@media') return;

    const body = element.children
      .map((child) => child.return || child.value)
      .join('');
    element.return = `@layer ${layerName}{${element.value}{${body}}}`;
  };
}

// WHY prepend AND a declared layer: prepend controls where the <style>
// element sits in the head; the manifest controls where the LAYER sits in
// the cascade. They are independent, and both are needed.
export const cache = createCache({
  key: 'app',
  prepend: true,
  stylisPlugins: [cssLayerPlugin('runtime')],
});
/* src/styles/entry.css — the manifest that gives `runtime` its position. */

/* WHY `runtime` between components and utilities: styled components are
   component-level styling, so they should beat the design system's base
   components but still lose to an explicit utility. */
@layer reset, vendor, base, theme, components, runtime, utilities, overrides;

@import url("./reset.css")      layer(reset);
@import url("./base.css")       layer(base);
@import url("./theme.css")      layer(theme);
@import url("./components.css") layer(components);
@import url("./utilities.css")  layer(utilities);
// src/entry-client.jsx and src/entry-server.jsx must BOTH use this cache.
// WHY: a mismatch produces unlayered server HTML and layered client output,
// which is visible as a style shift the moment hydration completes.
import { CacheProvider } from '@emotion/react';
import { cache } from './styles/emotion-cache';

export const Root = ({ children }) => (
  <CacheProvider value={cache}>{children}</CacheProvider>
);

Frequently Asked Questions

Why do CSS-in-JS styles override my cascade layers?

Because they arrive as unlayered author CSS. The library inserts a <style> element at runtime, and anything outside an @layer block outranks every layer regardless of specificity or position. Raising your own layer cannot help, because the winning declaration is not in the stack at all — the only fix is to get the injected text inside a layer, which is what the approaches on this page do.

Which layer should CSS-in-JS output go into?

A dedicated layer positioned with the component band — either components itself or a runtime layer immediately above it. That places runtime-styled components and design-system components on predictable terms while leaving utilities above both, so a utility still wins. Putting the runtime layer above utilities recreates the original problem, with the difference that it now looks intentional.

Does wrapping break server-side rendering?

Only when the two paths diverge. The failure mode is applying the layer configuration on the client while the server renders through a different cache, which produces unlayered markup on first paint and layered rules after hydration — visible as a style shift. Share one cache configuration between the server and client entry points and check the raw server response, not the inspector, when verifying.

Up: Framework Integration & Layer WrappingArchitecture Patterns & Design System Scaling