Sz Props Basics
The sz prop lets you write Tailwind CSS as a JavaScript object. The build
plugin transforms it to a className string at compile time — zero runtime
cost for static values.
Basic Usage
Section titled “Basic Usage”Every Tailwind utility has a corresponding sz prop key:
// Tailwind string syntax<div className="p-4 bg-blue-500 text-white rounded-lg" />
// Equivalent sz prop syntax — TypeScript validates each key and value<div sz={{ p: 4, bg: 'blue-500', color: 'white', rounded: 'lg' }} />Both produce identical HTML. In production, the classes are mangled to single characters.
Property Mapping
Section titled “Property Mapping”Tailwind class names map to camelCase sz prop keys:
| Tailwind class | sz prop |
|---|---|
p-4 | { p: 4 } |
px-6 | { px: 6 } |
bg-blue-500 | { bg: 'blue-500' } |
text-white | { color: 'white' } |
rounded-lg | { rounded: 'lg' } |
font-bold | { weight: 'bold' } |
flex | { display: 'flex' } |
hidden | { display: 'none' } |
Keys that need a build-time value
Section titled “Keys that need a build-time value”Most keys take a runtime value. sz={{ p: pad }} compiles to
p-(--_sz-p) plus a style custom property, and Tailwind serves that class.
A minority cannot, because Tailwind has no utility for them that reads a CSS variable. Passing one a variable used to produce a class that either styled the wrong property or matched nothing at all — silently. csszyx now drops it and says so:
// ❌ textAlign has no variable form — `text-(--v)` is a COLOUR in Tailwind v4<div sz={{ p: 4, textAlign }} />// → <div className="p-4" /> + a build report naming textAlign and the lineThe sibling p: 4 is unaffected — only that one property is dropped.
Three ways to write it, all fully build-time:
// 1. Two or three options — a ternary between literals<div sz={{ p: 4, textAlign: centered ? 'center' : 'left' }} />
// 2. A real set of options — name them with szv, read through szrconst alignSz = szv({ variants: { align: { left: { textAlign: 'left' }, center: { textAlign: 'center' },} } });<div className={szr(alignSz({ align: side }))} />
// 3. Genuinely open-ended data — dynamic(), which injects CSS at runtimeThe affected keys are the keyword-valued ones: display and layout modes, alignment, overflow, text alignment and transform, background sizing and repeat, border and outline style, and their neighbours. Each reference page lists its own, and Warnings → Keys that need a build-time value carries the complete table plus the exact message.
One key per property
Section titled “One key per property”Props that set a single CSS property are written with their canonical key and a value — one way, no aliases:
| CSS property | sz key | Example |
|---|---|---|
| display | display | { display: 'flex' }, { display: 'none' } |
| position | position | { position: 'absolute' } |
| visibility | visibility | { visibility: 'hidden' } |
| isolation | isolation | { isolation: 'isolate' } |
| text-transform | textTransform | { textTransform: 'uppercase' } |
| font-style | fontStyle | { fontStyle: 'italic' } |
| font-smoothing | fontSmoothing | { fontSmoothing: 'grayscale' } |
| text-decoration-line | decoration | { decoration: 'underline' } |
<div sz={{ display: "flex", position: "absolute", fontStyle: "italic" }} />True boolean utilities
Section titled “True boolean utilities”Some utilities are genuinely on/off — not a value of a single property — and stay
boolean: composite helpers (truncate, srOnly), stackable font-variant-numeric
flags (ordinal, tabularNums), default-or-value toggles (grow, ring,
blur), and plugin components (container, prose).
<div sz={{ truncate: true, tabularNums: true, grow: true, container: true }} />A later key covers an earlier one
Section titled “A later key covers an earlier one”Two keys can still set the same property: p sets all four paddings, pb only
the bottom one. In a static sz object or array, a later key replaces an earlier
key whose CSS it fully covers, so a refinement written after the shorthand
survives and one written before it does not:
<div sz={{ pb: 2, p: 4 }} /> // → className="p-4"<div sz={{ p: 4, pb: 2 }} /> // → className="p-4 pb-2" (pb refines p)<div sz={[{ w: 4 }, { size: 8 }]} /> // → className="size-8"Without this, both classes reach the element and the stylesheet decides.
Tailwind writes the shorthand’s rule first, so { pb: 2, p: 4 } used to render
a 0.5rem bottom padding whatever order you wrote.
“Later” is the key’s place in the finished object, which is where it first
appears. A spread, and a key repeated across array elements, keep that first
place: with const card = { p: 8 }, sz={{ ...card, pb: 2, p: 4 }} is the
object { p: 4, pb: 2 } — p came in with the spread — so it emits p-4 pb-2
and removes nothing, and sz={[{ pb: 2 }, { p: 4 }, { pb: 3 }]} is
{ pb: 3, p: 4 } and emits p-4.
Which key covers which is read from your project’s compiled Tailwind stylesheet,
not from key names: border: 2 sets a width and border: 'red-500' a colour. A
key replaces another only when it sets every property the other sets:
- in the same variant:
{ pb: 2, hover: { p: 4 } }keeps both; - with the same
!important: an important class and a plain one never replace each other; - with CSS behind it: a class your stylesheet generates nothing for is never removed;
- from Tailwind itself: a class from
@utility, a plugin or plain CSS is never removed and never removes another, even when it reuses a Tailwind name (@utility container), and neither is a Tailwind class that a rule of your own CSS selects on (.card.shadow-md { … },[class~="shadow-md"]). The build reads every stylesheet it walks, the<style>blocks of your Vue, Svelte and Astro components, HTML pages and JSX, and every arbitrary variant in your markup or yoursz(group-[.shadow-md]:p-2,group: { '.shadow-md': { p: 2 } }).
It applies where the build compiles the object. In this release the build merges only the keys it lowers statically; these keep every class:
- a value only the runtime knows (
{ pb: 2, p: size }) or a conditional value ({ pb: 2, p: big ? 8 : 4 }); - an
szvfactory and anszr({ … })call, which resolve at runtime; - a
.svelteor.vuefile, which the adapters compile without the project’s stylesheet; - a jest run before any build or
csszyx next prebuildhas written the table, and a tool that compiles without your stylesheet, such as the MCP preview.
Classes that can be resolved inside a runtime object stay in the safelist,
including every branch of a conditional spread under a variant. For example,
{ hover: { ...(active ? { pb: 2, p: 4 } : { p: 8 }) }, w: width } keeps
hover:pb-2 in the generated CSS even when a static object elsewhere in the
file merges pb-2 into p-4.
Under Next.js with Turbopack the table comes from csszyx next prebuild or
csszyx next watch; until one of them has run, both classes stay. After a
stylesheet edit that changes which class covers which, a Vite dev server
recompiles every module, and a watch build asks you to restart it.
Type safety — unknown keys are caught by TypeScript
Section titled “Type safety — unknown keys are caught by TypeScript”The sz prop type is closed: a key that isn’t a known sz prop or variant is a
TypeScript error, so a typo or a legacy CSS-property name fails at compile time. Run
tsc --noEmit in CI to enforce it across a codebase.
<div sz={{ bgColor: "red-500" }} /> // ❌ tsc error — unknown key; use { bg: 'red-500' }For untyped sz (string keys, dynamic objects, or JS files) the build prints a
dev-mode warning instead — but lazily, only when a route is opened. To catch every
file at once, run the standalone scan, which reports each unknown/aliased key with
its file:line and exits non-zero so CI can gate on it:
npx @csszyx/cli check # scan the whole project; no dev server neededThese dev-mode warnings print in Node contexts only — the build and SSR, never
the browser client. To keep the dev loop quiet and rely on csszyx check in CI
instead, mute them with CSSZYX_QUIET_SZ_WARNINGS=1:
CSSZYX_QUIET_SZ_WARNINGS=1 npm run dev # no inline sz warnings; use `check` to auditThey stay on by default — an unknown key means a dropped class, a correctness signal worth surfacing.
To deliberately use a brand-new Tailwind utility csszyx has no key for yet, opt out with
@ts-expect-error — a conscious decision; the runtime still emits the class:
{ /* @ts-expect-error - forward-compat utility not yet in csszyx */}<div sz={{ someNewUtility: "x" }} />;Arbitrary variants are allowed by pattern (@container, min-[320px], [&>span]).
Does sz auto-type on a custom component?
Section titled “Does sz auto-type on a custom component?”Only on host elements (<div>, <span>, …). The JSX augmentation adds sz to
React’s HTMLAttributes / SVGAttributes, so every DOM element accepts it. A custom
component has its own props type, so sz is not added there automatically.
Two separate layers, don’t conflate them:
- Compile — the transform lowers
sz→classNameon any element, custom included:<Card sz={{ p: 4 }} />becomes<Card className="p-4" />. It works at runtime as long asCardforwardsclassNamedown to a host element. - Type — TypeScript checks the source before the transform, so
<Card sz={…} />is a type error unlessCard’s props includesz.
Whether sz is typed depends on how the props are declared:
Card’s props type | sz typed? |
|---|---|
{ title: string } (fresh type) | ❌ TS error |
ComponentProps<'div'> / extends HTMLAttributes<T> | ✅ inherited |
{ title: string } & Pick<ComponentProps<'div'>, 'sz'> | ✅ just sz |
The Pick form is the tidiest way to add sz to a fresh props type — no import, and
it tracks csszyx’s own sz type:
import type { ComponentProps } from "react";
type CardProps = { title: string } & Pick< ComponentProps<"div">, "sz" | "className">;
function Card({ title, sz, className }: CardProps) { // The transform rewrites `sz` → `className` before Card runs, so pick `className` // too and forward it onto the host element. return ( <div sz={sz} className={className}> {title} </div> );}Pick only works when the augmentation is loaded (a /// <reference types="@csszyx/types/jsx" />
in scope, or the project’s csszyx-env.d.ts) — otherwise sz is not a key of
ComponentProps<'div'>.
Typing a component that forwards sz
Section titled “Typing a component that forwards sz”csszyx exposes the sz value type from two places, for two different jobs:
SzPropValue(@csszyx/compiler, re-exported by@csszyx/types) is what the JSX augmentation adds to host elements — the type ofszon<div>,<span>, ….SzInput(@csszyx/runtime) is what the runtime helpers (szr,szcn, …) accept — a wider union that also allows top-levelnull/false/undefined.
For a wrapper component that takes sz and forwards it onto a host element,
type the prop with the augmentation’s type so it lines up with the element it
lands on:
import type { SzPropValue } from "@csszyx/types";
function Box({ sz, ...rest}: { sz?: SzPropValue } & JSX.IntrinsicElements["div"]) { return <div sz={sz} {...rest} />;}Type the prop as SzPropValue and it forwards both ways: onto the host element
(same type) and into the runtime helpers, which accept it directly:
import type { SzPropValue } from "@csszyx/types";import { szr } from "@csszyx/runtime";
function Box({ sz, active }: { sz?: SzPropValue; active?: boolean }) { // `sz` flows into szr() without a cast, and onto <div> below. return <div sz={sz} className={szr(active && "ring-2")} />;}SzPropValue is assignable into the helpers’ SzInput, so you no longer need a
cast or @ts-expect-error when a wrapper both forwards sz and calls szr /
splitBoxSz. Prefer SzPropValue at the JSX boundary; reach for SzInput only
when a value genuinely originates from the runtime side.
Nested Variants
Section titled “Nested Variants”Variants (hover, focus, responsive breakpoints) use nested objects:
<button sz={{ bg: "blue-500", color: "white", px: 4, py: 2, rounded: "md", hover: { bg: "blue-600", }, focus: { outline: "none", ring: 2, ringColor: "blue-400", }, disabled: { opacity: 50, cursor: "not-allowed", }, }}/>Responsive Breakpoints
Section titled “Responsive Breakpoints”Responsive modifiers are nested objects with the breakpoint as the key:
<div sz={{ w: "full", // width: 100% on mobile md: { w: "1/2", // width: 50% at md+ }, lg: { w: "1/3", // width: 33% at lg+ }, }}/>Dark Mode
Section titled “Dark Mode”<div sz={{ bg: "white", color: "gray-900", dark: { bg: "gray-800", color: "white", }, }}/>Arbitrary Values
Section titled “Arbitrary Values”For one-off values not in the Tailwind scale, pass a string. The compiler
wraps it in [...] automatically:
<div sz={{ w: "333px", // w-[333px] bg: "#316ff6", // bg-[#316ff6] p: "1.25rem", // p-[1.25rem] top: "37px", // top-[37px] }}/>CSS Variables
Section titled “CSS Variables”Prefix any CSS custom property with -- and the compiler wraps it in (...):
<div sz={{ bg: "--my-brand-color", // bg-(--my-brand-color) color: "--text-primary", // text-(--text-primary) p: "--spacing-lg", // p-(--spacing-lg) }}/>Arbitrary CSS (css: {})
Section titled “Arbitrary CSS (css: {})”For CSS properties with no sz prop or Tailwind utility equivalent, use the
css escape-hatch. Keys are camelCase CSS properties; the compiler converts
them to [prop:value] arbitrary-property classes automatically.
<div sz={{ css: { writingMode: "vertical-lr", // [writing-mode:vertical-lr] touchAction: "none", // [touch-action:none] "--my-color": "red", // [--my-color:red] }, hover: { css: { cursor: "crosshair" }, // hover:[cursor:crosshair] }, md: { css: { writingMode: "horizontal-tb" }, // md:[writing-mode:horizontal-tb] }, }}/>The css key accepts all CSS.Properties keys plus CSS custom properties (--*) — full IDE autocomplete and typo protection.
Conditional Values
Section titled “Conditional Values”Pass a ternary expression as any property value. When both branches are static literals (string, number, boolean), the compiler compiles each branch at build time and emits a conditional class expression — no CSS variables, no inline styles:
<div sz={{ bg: isActive ? "blue-500" : "gray-200", color: hasError ? "red-600" : "gray-900", scale: shrunk ? 75 : 100, }}/>// Compiler emits:// className={`bg-blue-500 text-red-600 ${shrunk ? 'scale-75' : 'scale-100'}` …}// (each ternary prop compiled independently, static props merged into a single string)Works inside variant blocks too:
<div sz={{ p: 4, hover: { scale: isHovered ? 110 : 100 }, }}/>// hover branch: hover:scale-110 or hover:scale-100 — zero runtimeConditioning the whole sz, not one value
Section titled “Conditioning the whole sz, not one value”The same applies one level up. sz={cond ? A : B} compiles both branches to
class strings, and a branch that styles nothing — undefined, null, false,
or {} — is the empty style, not an unknown one, so it compiles too:
<div sz={disabled ? { color: "muted" } : undefined} />// → className={disabled ? "text-muted" : undefined}A guard is the same expression with the else arm left off, so it compiles the same way:
<div sz={compact && { p: 2 }} />// → className={compact ? "p-2" : undefined}|| is deliberately left on the runtime path. It yields its left operand
when the test passes, and that value can itself be a style — folding
sz={base || { p: 4 }} would drop base — so it keeps _sz().
Two shapes still resolve at runtime: a chain of tests (a ? x : b ? y : z), and
a branch holding a value only the runtime knows (cond ? { p: pad } : undefined).
Array Syntax
Section titled “Array Syntax”Pass an array to sz to compose styles with later-wins semantics: when two
elements touch the same property, the later element’s value overrides the
earlier one’s — like Object.assign, and like every class-merge tool you know.
A later key that covers an earlier one
replaces it too.
Elements can be sz objects, class strings, cond && … guards, or runtime
values (a forwarded szsc
slot).
// Fully static — deep-merged at build, compiled to ONE className, zero runtime<div sz={[{ text: 'base', p: 4 }, { text: 'lg' }]} />// → className="text-lg p-4" (text-lg overrode text-base)
<div sz={[{ hover: { bg: 'red-500' } }, { hover: { p: 2 } }]} />// → className="hover:bg-red-500 hover:p-2" (deep merge: siblings survive)
// Anything else — composed at runtime (same later-wins rule, applied per// property group, mangle-safe). The compiler injects `_szcn`, a generated// helper you never write by hand (the `_` marks compiler-injected code):<div sz={[ { p: 4, color: 'white' }, isActive && { bg: 'blue-500' }, szsc?.title, // dynamic element via _szPart]} />// → className={_szcn("p-4 text-white", isActive && "bg-blue-500", _szPart(szsc?.title))}
// Finite ternaries compile to class strings instead of deferring objects to _szPart:<a sz={[ { decoration: 'none' }, disabled ? { color: 'muted' } : { color: 'main' }, szsc?.link,]} />// → className={_szcn("no-underline", disabled ? "text-muted" : "text-main", _szPart(szsc?.link))}
// Falsy property branches contribute no utility token (never `flex-undefined`):<div sz={[{ decoration: 'none', flex: fluid ? 1 : undefined }, szsc?.root]} />// → className={_szcn("no-underline", fluid ? "flex-1" : "", _szPart(szsc?.root))}This makes sz={[defaults, override]} the one-liner for slot defaults in a
compound component — no className plumbing, no manual szcn call:
function Card({ title, szsc }: SzsProps<"title"> & { title: string }) { return ( <h3 sz={[{ weight: "semibold", text: "base" }, szsc?.title]}>{title}</h3> );}Array syntax is especially useful with szv() variant objects:
import { szv } from "csszyx";
const btn = szv({ base: { px: 4, py: 2, rounded: "md" }, variants: { intent: { primary: { bg: "blue-600", color: "white" } } },});
<button sz={[btn({ intent: "primary" }), isLoading && { opacity: 50 }]} />;Shared Style Objects
Section titled “Shared Style Objects”Pass a variable directly to sz when no properties need overriding — the
compiler resolves it at build time just like an inline object:
const item = { p: 3, rounded: "md", bg: "white" } as const;
<div sz={item} />; // → className="p-3 rounded-md bg-white"Use object spread (sz={{ ...var, ... }}) only when you need to override or
add properties. Last key wins:
const card = { p: 6, rounded: 'xl', shadow: 'md' } as const;
<div sz={{ ...card, p: 4 }} /> // p: 4 overrides card's p: 6<div sz={{ shadow: 'sm', ...card }} /> // card's shadow: 'md' winsMultiple spreads and nested variant objects are also resolved statically at build time:
const layout = { display: "flex", gap: 4 };const colors = { bg: "blue-500", color: "white" };
<div sz={{ ...layout, ...colors, hover: { opacity: 75 } }} />;// → className="flex gap-4 bg-blue-500 text-white hover:opacity-75"Conditional Spread with Static Overrides
Section titled “Conditional Spread with Static Overrides”When the base style depends on a runtime condition but additional properties are fixed, spread the ternary inline. The compiler hoists the condition outward and resolves each branch separately — still zero runtime cost:
const active = { bg: "blue-500", color: "white" } as const;const inactive = { bg: "gray-100", color: "gray-600" } as const;
// rotate is always 45 — compiler emits: isActive ? "bg-blue-500 text-white rotate-45" : "bg-gray-100 text-gray-600 rotate-45"<div sz={{ ...(isActive ? active : inactive), rotate: 45 }} />;Each resolved branch gets the same key and value checks as a plain object, so a misspelled key in either one is reported at build time.
This works as long as:
- Exactly one conditional spread (
...(cond ? a : b)) - The static overrides are compile-time values (literals, variables)
When either condition isn’t met, the compiler falls back gracefully (see below).
Combining with className
Section titled “Combining with className”The sz prop and className prop can coexist. The compiler puts sz last:
<div sz={{ p: 4, bg: "blue-500" }} className="custom-class" />// → className="custom-class p-4 bg-blue-500"That order is the contract, and it holds whichever order you write the two attributes in — JSX attribute order carries no meaning here, as long as no spread stands between them.
A spread between them is the exception. It may carry a class name of its own,
and the platform already decides between attributes written on either side of
one: in React, Preact, Solid and Qwik the later one replaces the earlier. So a
spread is a wall. The class names and sz on one side of it merge as usual, and
each side becomes one class name where its sz stood:
<div className="card" {...rest} sz={{ p: 4 }} />// → className="card" {...rest} className="p-4" (React renders class="p-4")
<div className="card" sz={{ p: 4 }} {...rest} sz={{ m: 2 }} />// → className="card p-4" {...rest} className="m-2"The build warns, in production too, which classes will not apply, and writes
the fix from your element. To keep them, write everything in one sz array
after the spread, in the order that should win:
sz={['card', rest.className, { p: 4 }]}. In Solid the attribute is class,
so the spread’s is rest.class. Vue JSX merges every class instead, so there
all of them apply.
In a Svelte component two class attributes on one element are an error, so
Svelte stops the build with attribute_duplicate: Attributes need to be unique, pointing at the class csszyx wrote where sz stood. The Svelte
adapter compiles only an sz object, not an array, so there put the class and
sz on the same side, after the spread, and read the spread’s class inside
the string:
<div {...rest} class="card {rest.class ?? ''}" sz={{ p: 4 }}></div><!-- → class="card {rest.class ?? ''} p-4" -->When className is a dynamic expression, the merge runs at runtime and is
last-wins per conflicting utility, so sz beats className on the same
element: a className carrying p-8 beside sz={{ p: 4 }} leaves p-4,
and the p-8 is removed rather than losing in the cascade.
// className is dynamic → _szMerge(className, sz) at runtime<div sz={{ p: 4 }} className={baseClass} />sz over a static className
Section titled “sz over a static className”When className is a static string the build resolves the conflict too: a
Tailwind class in className is dropped when a class the sz beside it emits
sets every property it sets, read from your compiled stylesheet as in
the object rule.
<div className="card pb-2" sz={{ p: 4 }} />// → className="card p-4"Only the class name loses classes, and only to sz: two classes inside a
static className are never compared (className="pb-2 p-8" keeps both, and
the stylesheet order decides between them), and the sz classes come out as
they would with no className. A class that sets only part of what an sz class
sets stays, and so does one with a different variant or !important. The
exceptions of the object rule apply: a class from @utility, a plugin or
plain CSS, and a Tailwind class that a stylesheet, a component’s <style>
block or an arbitrary variant selects on, is never removed. A selector the
build cannot read, in CSS-in-JS or in a script (querySelector('.shadow-md')),
is not a hook, so a class kept only for one is safest under a name Tailwind
does not generate (card-body) or as a data-* attribute.
A build that meets such a selector only after it merged, from a module outside its walk or an edit during a watch, stops and names the class, rather than ship a page without it.
To keep a className refinement, write it in sz after the key that covers
it: sz={{ p: 4, pb: 2 }}. build.mergeCoveredClasses: false turns both rules
off.
A className expression, a ternary sz and a runtime sz merge at runtime
through szcn instead, where every class is compared with every other and the
later one wins, inside the class name too. The same classes can therefore come
out differently written two ways:
<div className="pb-2 p-8" sz={{ m: 4 }} /> // → "pb-2 p-8 m-4"<div className={"pb-2 p-8"} sz={{ m: 4 }} /> // → "p-8 m-4" at runtimeWrite a refinement last and in sz and both forms agree.
This is worth knowing before you write a wrapper component. A caller’s sz is
demoted to className at the call site, so it arrives on the losing side of
your own sz and is silently dropped. To state the precedence explicitly —
either direction — put both in one sz array, where position decides:
<div sz={[{ p: 4 }, className]} /> // the caller wins<div sz={[className, { p: 4 }]} /> // these styles wincsszyx reports the ambiguous shape as a nudge; either rewrite silences it. See compound components for the wrapper case in full.
Two sz attributes on one element
Section titled “Two sz attributes on one element”An element with more than one sz — a merge conflict, a codemod, a copy-paste —
is read as one array in source order:
<div sz={{ p: 4, bg: "blue-500" }} sz={{ p: 2 }} />// same as<div sz={[{ p: 4, bg: "blue-500" }, { p: 2 }]} />// → className="p-2 bg-blue-500"Later wins per property, one className is emitted, and an authored
className stays first. This holds whatever shape each sz has: two arrays
join their elements, a ternary or a runtime value becomes one element of the
composition. JSX compilers pass a duplicate attribute through as a duplicate
object key, where the last one silently replaces the first; csszyx keeps both
and says so. The build reports the element as a nudge — write the array
instead, so the order is on the page.
Runtime Helpers for Dynamic Classes
Section titled “Runtime Helpers for Dynamic Classes”When class names depend on runtime values, use the helper functions:
import { _sz } from 'csszyx';
// Concatenate multiple class strings<div className={_sz('p-4 bg-blue-500', extraClass)} />
// Conditional: plain JS conditionals compose with _sz<div className={_sz('base', isActive && 'ring-2 ring-blue-400')} />
// Switch: a plain object lookup<div className={{ primary: 'bg-blue-600 text-white', danger: 'bg-red-600 text-white',}[variant] ?? 'bg-gray-500 text-gray-900'} />