@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.
When to use
Section titled “When to use”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, whichszv()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 likesz={{ p: condition ? 4 : 2 }}andszv()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 case | Approach |
|---|---|
| Styles defined in source code | sz prop (build-time, zero runtime) |
| Conditional / variant styles (finite, known) | szv() + sz array syntax (build-time) |
Resolving szv output to a className by hand | szr() (build-time-safe, mangle-aware) |
| Styles from JSON / API / user config (unbounded) | @csszyx/dynamic (runtime injection) — escape hatch |
Install
Section titled “Install”npm install @csszyx/dynamic# or use the umbrella:npm install csszyx # csszyx/dynamic is includedFramework-agnostic
Section titled “Framework-agnostic”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 neededconst style = { p: 4, bg: 'blue-500' } as const;const cls2 = dynamic(style); // ✅React hook — useSz()
Section titled “React hook — useSz()”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.
React hook — useDynamicScope()
Section titled “React hook — useDynamicScope()”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)} />;}The manifest, and why it is off by default
Section titled “The manifest, and why it is off by default”@csszyx/dynamic can fetch /csszyx-manifest.json to check which classes are
already in the pre-built stylesheet and skip injecting duplicates. The build does
not emit that file unless asked:
...csszyx({ build: { emitManifest: true } })Leaving it off is safe. A missing manifest means dynamic() treats nothing as
pre-built and generates its own rules, so the rendered styles are identical —
what changes is bytes.
And bytes are the reason for the default. The manifest lists the whole class
census, while dynamic() only asks about the classes it actually renders.
Measured on a 668-class census (pnpm bench:dynamic-manifest), the file costs
about 2 kB gzipped no matter what, while injecting the classes a
10%-runtime app renders costs about 500 B. It only comes out ahead once most of
the app is styled at runtime.
Not sure which side your app is on? Measure it:
import { dynamicReport } from '@csszyx/dynamic';
// after the app has exercised the paths that matterconsole.log(dynamicReport().summary);// "Manifest cost 2078 B and spared only 340 B of injected CSS —// set build.emitManifest to false."Delta injection — only missing CSS
Section titled “Delta injection — only missing CSS”On each dynamic() call:
transform(szProps)→ Tailwind class string (same logic as the build-time compiler)- Each class is looked up in the manifest, when one was loaded:
- In manifest → use the resolved name (mangled in production builds)
- Not in manifest, or no manifest → generate CSS rule + inject into a
CSSStyleSheettier
- 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.
Build-time extraction (Layer-1 prescan)
Section titled “Build-time extraction (Layer-1 prescan)”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 automaticallyconst 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.
SSR safety
Section titled “SSR safety”On the server, dynamic() returns class names without touching CSSOM. There is no
document access in SSR environments.
Manifest generation
Section titled “Manifest generation”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 productionimport csszyx from 'csszyx/vite';
export default defineConfig({ plugins: [...csszyx(), tailwindcss(), react()],});With build: { emitManifest: true }, the manifest file
(csszyx-manifest.json) is written to the build output so it is served as a
static asset. Without it — the default — nothing is emitted and dynamic()
injects its own rules.
Use with form renderers (RJSF, Formily, etc.)
Section titled “Use with form renderers (RJSF, Formily, etc.)”// Store sz style in JSON schemaconst schema = { uiSchema: { 'ui:sz': { bg: 'white', p: 4, hover: { bg: 'gray-50' }, dark: { bg: 'gray-900' } } }};
// Apply at render timefunction 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.
Untrusted sz — clean it with purifySz
Section titled “Untrusted sz — clean it with purifySz”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.