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