Skip to content

@csszyx/dynamic — Runtime CSS Injection

@csszyx/dynamic enables sz-style objects from external sources (JSON config, API responses, CMS, form renderer schemas) to be applied at runtime. CSS is injected only for classes not already present in the pre-built stylesheet.

dynamic() is an escape hatch, not a default. It is the only csszyx helper that generates CSS at runtime (it injects rules into the page on the fly), so it carries a real cost the build-time path does not: runtime work, a larger runtime bundle, a security surface for untrusted input, and its classes are not mangled. Reach for it only when the build genuinely cannot know the styles ahead of time:

  • Use dynamic() when the style values come from runtime data you don’t control at build — a JSON theme, an API response, user config, a CMS: sz={{ w: valueFromServer }}.
  • ✅ Or, rarely, when a style genuinely cannot be expressed with the build-time helpers (sz / szv / szr / szcn). This is uncommon — most “dynamic-looking” cases are actually a fixed set of options, which szv() covers at build time.
  • Do not use dynamic() for styles written in your source, conditional styles, or a known set of variants — those are all build-time. A literal like sz={{ p: condition ? 4 : 2 }} and szv() variants are extracted and safelisted at build with zero runtime CSS injection.

Default to build-time. If a value is a literal or a finite set of choices, it belongs in sz / szv — not dynamic().

Use caseApproach
Styles defined in source codesz prop (build-time, zero runtime)
Conditional / variant styles (finite, known)szv() + sz array syntax (build-time)
Resolving szv output to a className by handszr() (build-time-safe, mangle-aware)
Styles from JSON / API / user config (unbounded)@csszyx/dynamic (runtime injection) — escape hatch
Terminal window
npm install @csszyx/dynamic
# or use the umbrella:
npm install csszyx # csszyx/dynamic is included
import { dynamic } from '@csszyx/dynamic';
// or:
import { dynamic } from 'csszyx/dynamic';
const cls = dynamic({ p: 4, bg: 'blue-500', hover: { bg: 'blue-600' } });
// → "p-4 bg-blue-500 hover:bg-blue-600"
// CSS for missing classes is injected into the page automatically.
// `as const` objects are supported — no `as any` cast needed
const style = { p: 4, bg: 'blue-500' } as const;
const cls2 = dynamic(style); // ✅
import { useSz } from '@csszyx/dynamic/react';
// or:
import { useSz } from 'csszyx/dynamic/react';
function DynamicCard({ style }: { style: SzObject }) {
const { sz } = useSz();
return <div className={sz(style)} />;
}

useSz() wraps dynamic() and memoises results by input object identity.

For components that inject classes for a bounded lifetime (e.g. a form renderer widget that unmounts when the form closes):

import { useDynamicScope } from '@csszyx/dynamic/react';
function FormWidget({ schema }) {
const { sz, cleanup } = useDynamicScope();
useEffect(() => {
return cleanup; // injected stylesheets removed on unmount
}, [cleanup]);
return <div className={sz(schema.style)} />;
}

@csszyx/dynamic fetches /csszyx-manifest.json (written by the build plugin) to check which classes are already in the pre-built stylesheet. Without preloading, the manifest is lazy-fetched on the first dynamic() call.

import { preloadManifest } from '@csszyx/dynamic';
// In your app entry — preload before first render for zero-latency inject
await preloadManifest('/csszyx-manifest.json');

On each dynamic() call:

  1. transform(szProps) → Tailwind class string (same logic as the build-time compiler)
  2. Each class is looked up in the manifest:
    • In manifest → use the resolved name (mangled in production builds)
    • Not in manifest → generate CSS rule + inject into a CSSStyleSheet tier
  3. Return the final class string

This means a <div className={sz({ p: 4 })} /> inside a form renderer widget that is also using p-4 in the main app will reuse the existing CSS — no duplicate rule injected.

When dynamic() receives a static literal or a module-level const reference, the compiler extracts all classes at build time and adds them to the Tailwind safelist. Tailwind pre-generates the CSS — no runtime injection needed.

// Static literal — classes extracted at build time
<div className={dynamic({ w: 7, h: 8, rounded: 'sm' })} />
// Const reference — compiler resolves it automatically
const boxStyles = { w: 7, h: 8, rounded: 'sm' } as const;
<div className={dynamic(boxStyles)} />

This is especially useful in Astro SSR without client:* — the CSS is already in the built stylesheet, so dynamic() finds the classes in the manifest and returns them with zero CSSOM work.

For truly runtime-dynamic values (variables, API data), the standard browser injection path applies as normal.

On the server, dynamic() returns class names without touching CSSOM. There is no document access in SSR environments.

The build plugin writes the manifest automatically when you use the csszyx Vite or Webpack plugin. No extra config needed.

// vite.config.ts — manifest is written automatically in production
import csszyx from 'csszyx/vite';
export default defineConfig({
plugins: [...csszyx(), tailwindcss(), react()],
});

The manifest file (csszyx-manifest.json) is output to the public/ directory so it is served as a static asset.

Use with form renderers (RJSF, Formily, etc.)

Section titled “Use with form renderers (RJSF, Formily, etc.)”
// Store sz style in JSON schema
const schema = {
uiSchema: {
'ui:sz': { bg: 'white', p: 4, hover: { bg: 'gray-50' }, dark: { bg: 'gray-900' } }
}
};
// Apply at render time
function RenderedField({ uiSchema }) {
const { sz } = useSz();
return <div className={sz(uiSchema['ui:sz'])} />;
}

This is the primary target use case: form renderers that store component definitions in JSON and need hover:, dark:, responsive: variants from user-supplied config.

When the sz object comes from a source you don’t fully control (a CMS, a saved user theme, any JSON you didn’t author), pass it through purifySz before dynamic() / useSz. It is allowlist-based: it keeps only keys csszyx recognizes, drops values that aren’t safe CSS, blocks prototype-polluting keys, and bounds nesting depth.

import { dynamic, purifySz } from '@csszyx/dynamic';
const className = dynamic(purifySz(untrustedSzFromJson));
// Report what was dropped, and relax the default strict mode if you need url()/image-set()
purifySz(input, {
strict: false,
onDrop: (path, reason) => console.warn(`dropped ${path}: ${reason}`),
});

Compiled or hand-authored sz from your own code does not need purifySz — use it only at the untrusted boundary. See the security guide for why the dynamic path is treated as an untrusted sink.