Integrate with a React renderer

Distillate does not ship a React runtime. A host renderer owns the class-name context and decides when to collect.

Register modules at module-eval time. Emit one stylesheet (build step or createDomSink) and put handle.readableName on className. No collector is required at render time:

import { createDistillery, cq, lightDark } from '@elastic/distillate';
import type { ReactNode } from 'react';

const distillery = createDistillery({
  prefix: 'eui',
  themeScope: '.eui-view',
  theme: {
    colors: { ink: lightDark('#111', '#eee') },
    gap: cq('8px', '2cqi'),
  },
});

const { handles } = distillery.createStyleModule('button', ({ css, tokens }) => ({
  root: css`
    color: ${tokens.colors.ink};
  `,
}));

export const Button = ({ children }: { children: ReactNode }) => (
  <button className={handles.root.readableName}>{children}</button>
);

// writeFileSync(
//   'dist/styles.css',
//   distillery.renderStyles(distillery.stylesheetCollector())
// );
		

Collection has to observe the tree. Compact names depend on the full collected set, so render twice: once to useHandles, once to write class names. combineClassNames is the component-facing API so views never mention the collector.

Reuse the distillery and button module from above. The extra pieces are a context, views that call combineClassNames, and a two-pass helper. Imports in the next snippet cover the rest of this section.

Both render passes implement this shape. Views only ever see resolveClassName:

import {
  combineClassNames,
  type Distillery,
  type StyleHandle,
  type StylesModule,
} from '@elastic/distillate';
import { createContext, useContext, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

interface StyleContextValue {
  resolveClassName: (...handles: StyleHandle[]) => string;
}

const StyleContext = createContext<StyleContextValue | null>(null);

const useStyleContext = (): StyleContextValue => {
  const value = useContext(StyleContext);
  if (!value) {
    throw new Error('StyleContext is missing.');
  }
  return value;
};
		

Same handles.root as the app button. Swap readableName for the context:

const Button = ({ children }: { children: ReactNode }) => {
  const ctx = useStyleContext();
  return <button className={combineClassNames(ctx, handles.root)}>{children}</button>;
};
		

Pass every handle the element should wear: combineClassNames(ctx, handles.root, other.handles.title).

The first pass must finish before any class name is printed. createResolver() assigns compact names from the collected set; reading them earlier mints names too soon. Leave globals empty unless you have injectGlobal styles (next section).

export const renderArtifact = (
  tree: ReactElement,
  distillery: Distillery,
  globals: readonly StylesModule[] = []
): { html: string; css: string } => {
  const collector = distillery.artifactCollector('compact');
  for (const module of globals) {
    collector.use(module);
  }

  // Pass 1: record handles. Compact names are not assigned yet.
  const collecting: StyleContextValue = {
    resolveClassName: (...handles) => {
      collector.useHandles(handles);
      return '';
    },
  };

  renderToStaticMarkup(
    <StyleContext.Provider value={collecting}>{tree}</StyleContext.Provider>
  );

  const resolver = collector.createResolver();

  // Pass 2: write class names now that the collected set is complete.
  const emitting: StyleContextValue = {
    resolveClassName: (...handles) =>
      collector
        .useHandles(handles)
        .map((handle) => resolver.className(handle.key, handle.readableName))
        .join(' '),
  };

  const html = renderToStaticMarkup(
    <StyleContext.Provider value={emitting}>{tree}</StyleContext.Provider>
  );
  return { html, css: distillery.renderStyles(collector, resolver) };
};

renderArtifact(<Button>Hello</Button>, distillery);
		

A readable artifact can skip the emitting pass because handle.readableName does not depend on the collected set. For a live tree, use a live readable collection. See Ship a compact artifact.

Globals from injectGlobal are separate modules. They are not implied by collecting handles; pass globalModules() into the helper when they should ship with the artifact:

import { createEmotion } from '@elastic/distillate/emotion';

const { injectGlobal, globalModules } = createEmotion(distillery);

injectGlobal`
  html {
    color-scheme: light dark;
  }
`;

renderArtifact(<Button>Hello</Button>, distillery, globalModules());
		

Do not mix a compact stylesheet with markup that used readableName. The DOM will carry .button-root while the <style> contains .a.

A host that renders into a shadow root, or any isolated root, needs only the CSS that tree resolves. Readable names do not depend on the collected set, so one live render is enough. distillery.liveCollection() returns { resolveClassName, css, collector }, which already fits the class-name context above:

import { createDomSink } from '@elastic/distillate';
import { useState } from 'react';
import { createPortal } from 'react-dom';

export const ShadowStyles = ({
  root,
  children,
}: {
  root: ShadowRoot;
  children: ReactNode;
}) => {
  const [live] = useState(() =>
    distillery.liveCollection({
      sink: createDomSink({ document: root.ownerDocument, parent: root }),
    })
  );
  return (
    <StyleContext.Provider value={live}>
      {createPortal(children, root)}
    </StyleContext.Provider>
  );
};
		

Every handle, rule, or theme var the collection has not seen invalidates the sink. The sink rewrites its one <style> in a microtask, which lands before paint. A subtree that suspends and resolves later adds its handles and triggers another flush, so there is no "collection finished" moment to guess.

Add globals and extra theme vars through live.collector; they invalidate the sink the same way:

for (const module of globalModules()) {
  live.collector.use(module);
}
live.collector.useThemeVar('colors/ink');
		

css() returns the CSS collected so far, for a host that writes it elsewhere. Pass { render } to forward renderStyles options such as scheme or alternates.

The collection only grows. A render React discards may leave extra CSS, never missing CSS. Compact names depend on the complete collected set, so a live collection is readable only; compact artifacts still render twice.