Skip to content

Style Engines

Jake Uskoski edited this page Jun 26, 2026 · 1 revision

🔄 Auto-generated mirror. Canonical source: docs/style-engines.md. Edit there — changes here are overwritten by CI.

Style engines (CSS-in-JS: MUI / emotion)

Nav: Quick start · Writing cases · Hierarchy · Tweaks · Theming · Style engines · Documentation panel · Writing placard docs · Testing · CLI · AI agents · Configuration

Display Case delivers every surface rendered and styled before scripts run. For static CSS that means globalStyles; for a theme provider or context it means the decorator. But libraries like Material UI style components with emotion, a runtime CSS-in-JS engine that emits CSS while the component renders. Server rendering keeps the markup but, without help, throws that styling away — so a MUI snapshot comes back unstyled and a live preview flashes unstyled before the client runtime catches up.

A style engine closes that gap. It does the two things a runtime CSS-in-JS library needs on the server: give each render an isolated style store, then read that render's critical CSS back into the document head before scripting.

The shape of an engine

import type { StyleEngine } from '@awarebydefault/display-case'

// A StyleEngine is a factory called once per server render. It returns a
// collector with two methods:
//   wrap(node)    — wrap the tree in the library's provider over a FRESH store
//   collect(html) — after render, return the <head> markup (style tags) it used

A fresh store per render is what keeps one case's styling out of another's snapshot, so the engine must be a factory (() => …), not a single object. Engines are configured on styleEngines, and apply to the isolated /render document and the Primer — the two surfaces that render your components in-document.

Material UI / emotion (the flagship)

Two pieces, working together:

  1. styleEngines — the server-only emotion extractor (this seam).
  2. decorator — the ThemeProvider your components need, on both server and client. On the client, emotion's default cache adopts the server styles automatically, so there is no flash and no duplication.

1. The emotion engine

// display-case.style-engine.tsx
import createCache from '@emotion/cache'
import { CacheProvider } from '@emotion/react'
import createEmotionServer from '@emotion/server/create-instance'
import type { StyleEngine } from '@awarebydefault/display-case'

export const emotionEngine: StyleEngine = () => {
  // A fresh cache per render → per-render isolation. Key `css` is emotion's
  // default, which the client runtime adopts automatically (no client wiring).
  const cache = createCache({ key: 'css' })
  cache.compat = true // required for extractCritical-style extraction
  const { extractCriticalToChunks, constructStyleTagsFromChunks } =
    createEmotionServer(cache)
  return {
    wrap: (node) => <CacheProvider value={cache}>{node}</CacheProvider>,
    collect: (html) =>
      constructStyleTagsFromChunks(extractCriticalToChunks(html)),
  }
}

2. Wire it into the config

// display-case.config.tsx
import { defineConfig } from '@awarebydefault/display-case'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import { emotionEngine } from './display-case.style-engine'

const theme = createTheme({ /* your palette, typography, … */ })

export default defineConfig({
  title: 'My MUI library',
  roots: ['src/**/*.case.tsx'],
  // Server: extract emotion's critical CSS into the document head.
  styleEngines: [emotionEngine],
  // Server + client: the provider your MUI components need. CssBaseline is
  // optional but makes the preview match your app.
  decorator: ({ children }) => (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      {children}
    </ThemeProvider>
  ),
})

That is the whole integration. Now:

  • /render/<component>/<case>?theme=dark fetched without scripts comes back fully styled — a real snapshot, not a skeleton.
  • The live preview paints styled on first frame: no flash of unstyled content.
  • When scripts run, emotion finds its data-emotion style tags already in the document and adopts them — nothing is re-injected or duplicated.

Why the pairing

Concern Where it lives Runs on
Fresh emotion cache + critical-CSS extraction styleEngines server only
ThemeProvider / CssBaseline (and the client cache emotion adopts into) decorator server + client

Keep their cache keys aligned. With emotion's default key (css) the client cache is implicit and adoption is automatic — which is why MUI needs no client-side engine code.

Other emotion-like libraries

Any runtime CSS-in-JS library that exposes "provide a store, then read critical CSS" fits the same wrap + collect contract.

styled-components:

import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
import type { StyleEngine } from '@awarebydefault/display-case'

export const styledComponentsEngine: StyleEngine = () => {
  const sheet = new ServerStyleSheet()
  return {
    wrap: (node) => (
      <StyleSheetManager sheet={sheet.instance}>{node}</StyleSheetManager>
    ),
    collect: () => sheet.getStyleTags(), // reads the sheet, not the html
  }
}

styled-components does not auto-adopt the way emotion's default key does, so add a matching client provider in your decorator if you see a re-style on hydration.

Multiple engines

styleEngines is an ordered array; engines nest in array order (the first is outermost) and their head output is concatenated. Use this only if a single showcase genuinely mixes two runtimes:

styleEngines: [emotionEngine, styledComponentsEngine]

When you do not need a style engine

  • Static CSS (Tailwind's compiled output, hand-written stylesheets, design tokens) → list it in globalStyles. Tailwind: build your CSS, then point globalStyles at the output file.
  • Zero-runtime CSS-in-JS (Vanilla Extract, Linaria, MUI Pigment CSS) → these emit static CSS at build time; treat the emitted file as globalStyles.
  • A component that must run in a browser anyway → mark it browserOnly; it is exempt from server styling and mounts (and styles) in the client. You lose the pre-scripting snapshot for that case.

Contract for a custom engine

If you write your own engine, it must:

  • Be a factory — return a new collector (and a new store) on every call. A shared store leaks one render's styling into another's document.
  • Have an idempotent collect — the case tree renders inside StrictMode (it may render twice); collect must return the same styling regardless.
  • Return complete head markup from collect (e.g. <style …>…</style>), including whatever attributes your client runtime keys on to adopt the styles — Display Case places the string verbatim, after the document's static styles, and does not parse it.
  • Pair with a client provider in decorator if your library does not adopt server styles automatically.

See also

Clone this wiki locally