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

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

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 szclassName 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

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

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

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 merges them:

<div sz={{ p: 4, bg: "blue-500" }} className="custom-class" />
// → className="p-4 bg-blue-500 custom-class"

When className is a dynamic expression (not a static string), the compiler uses _szMerge at runtime to join them safely:

// className is dynamic → _szMerge at runtime
<div sz={{ p: 4 }} className={baseClass} />

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