Skip to content

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.

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.

Tailwind class names map to camelCase sz prop keys:

Tailwind classsz 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' }

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 line

The 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 szr
const 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 runtime

The 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.

Props that set a single CSS property are written with their canonical key and a value — one way, no aliases:

CSS propertysz keyExample
displaydisplay{ display: 'flex' }, { display: 'none' }
positionposition{ position: 'absolute' }
visibilityvisibility{ visibility: 'hidden' }
isolationisolation{ isolation: 'isolate' }
text-transformtextTransform{ textTransform: 'uppercase' }
font-stylefontStyle{ fontStyle: 'italic' }
font-smoothingfontSmoothing{ fontSmoothing: 'grayscale' }
text-decoration-linedecoration{ decoration: 'underline' }
<div sz={{ display: "flex", position: "absolute", fontStyle: "italic" }} />

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 }} />

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 your sz (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 szv factory and an szr({ … }) call, which resolve at runtime;
  • a .svelte or .vue file, which the adapters compile without the project’s stylesheet;
  • a jest run before any build or csszyx next prebuild has 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:

Terminal window
npx @csszyx/cli check # scan the whole project; no dev server needed

These 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:

Terminal window
CSSZYX_QUIET_SZ_WARNINGS=1 npm run dev # no inline sz warnings; use `check` to audit

They 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]).

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 → className on any element, custom included: <Card sz={{ p: 4 }} /> becomes <Card className="p-4" />. It works at runtime as long as Card forwards className down to a host element.
  • Type — TypeScript checks the source before the transform, so <Card sz={…} /> is a type error unless Card’s props include sz.

Whether sz is typed depends on how the props are declared:

Card’s props typesz 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'>.

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 of sz on <div>, <span>, ….
  • SzInput (@csszyx/runtime) is what the runtime helpers (szr, szcn, …) accept — a wider union that also allows top-level null / 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.

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 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+
},
}}
/>
<div
sz={{
bg: "white",
color: "gray-900",
dark: {
bg: "gray-800",
color: "white",
},
}}
/>

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]
}}
/>

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)
}}
/>

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.

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 runtime

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).

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 }]} />;

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' wins

Multiple 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"

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).

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} />

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 runtime

Write 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 win

csszyx reports the ambiguous shape as a nudge; either rewrite silences it. See compound components for the wrapper case in full.

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.

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'} />