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 }} />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.
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.
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 compiler joins the two lists as shown
above and does not resolve a conflict between them at build time: with
className="p-8" sz={{ p: 4 }} both classes reach the DOM and the stylesheet
order decides. If the two can overlap, put both in one sz array — position
then states the winner and the merge is the later-wins one.
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'} />