Styling Component Parts
Sometimes one component has several parts you want to style separately — a card
with a header, body, and icon. csszyx has no special API for this: because the
sz prop compiles to className on any element (host tags, custom components,
and dotted names like Card.Header), you build a normal React compound
component and give each part its own sz.
Each part’s sz is compiled to Tailwind classes at build time and added to the
safelist — exactly like sz on a <div>. No runtime cost, and it mangles in
production the same way.
The idea in one line
Section titled “The idea in one line”<Card sz={{ p: 4 }}> <Card.Header sz={{ bg: 'gray-100', font: 'bold' }}>Title</Card.Header> <Card.Body sz={{ text: 'sm' }}>Body copy</Card.Body></Card>Every sz above is a separate build-time style on a separate part. Nothing is
resolved at runtime.
Build a compound component
Section titled “Build a compound component”The pattern is plain React: a parent component plus sub-components attached as
properties. Each sub-component forwards sz (which the transform has already
rewritten to className) onto a host element.
import type { ComponentProps, PropsWithChildren } from 'react';
// Each part derives its props from a host element, so `sz` + `className` are// already typed (see "Typing" below). The transform rewrites `sz` → `className`// before the component runs, so the component reads `className`.function Card({ className, children }: ComponentProps<'div'>) { return <div className={className}>{children}</div>;}
function CardHeader({ className, children }: ComponentProps<'header'>) { return <header className={className}>{children}</header>;}
function CardBody({ className, children }: ComponentProps<'div'>) { return <div className={className}>{children}</div>;}
// Attach the parts so consumers can write <Card.Header>.Card.Header = CardHeader;Card.Body = CardBody;
export { Card };Now a consumer styles each part independently:
<Card sz={{ p: 4, rounded: 'lg', shadow: 'md' }}> <Card.Header sz={{ bg: 'gray-100', p: 3, font: 'bold' }}> Invoice </Card.Header> <Card.Body sz={{ p: 3, text: 'sm', textColor: 'gray-700' }}> Amount due: $42 </Card.Body></Card>At build time this becomes:
<Card className="p-4 rounded-lg shadow-md"> <Card.Header className="bg-gray-100 p-3 font-bold">Invoice</Card.Header> <Card.Body className="p-3 text-sm text-gray-700">Amount due: $42</Card.Body></Card>All six classes are safelisted, so real Tailwind CSS is generated, and they mangle in a production build like any other csszyx class.
Merge a part’s own defaults with the consumer’s sz
Section titled “Merge a part’s own defaults with the consumer’s sz”A part usually has base styles of its own. The transform turns the consumer’s
sz into a className string, so combine it with the part’s defaults using
szcn (mangle-aware, last-wins on a conflicting
utility) or plain clsx for concat-only:
import { szcn } from '@csszyx/runtime';
function CardHeader({ className, children }: ComponentProps<'header'>) { // Base header styles, then the consumer's className wins on any conflict. return ( <header className={szcn('flex items-center gap-2', className)}> {children} </header> );}The part’s own base styles can be written as a sz object too — put them on the
element and let csszyx compile them:
function CardHeader({ className, children }: ComponentProps<'header'>) { // `sz` here is the part's OWN default; `className` is the consumer override. return ( <header sz={{ display: 'flex', items: 'center', gap: 2 }} className={className}> {children} </header> );}Typing
Section titled “Typing”Deriving each part’s props from a host element (ComponentProps<'div'>,
ComponentProps<'header'>, …) gives you sz and className for free, because
the JSX augmentation adds sz to every host element. If a part has extra props of
its own, intersect them:
type CardHeaderProps = ComponentProps<'header'> & { collapsible?: boolean };If you prefer a fresh props type, add just sz (and className) with Pick:
type Props = { title: string } & Pick<ComponentProps<'div'>, 'sz' | 'className'>;See Sz Props Basics → custom components for the full typing rules.
When to reach for this
Section titled “When to reach for this”- Use compound components when each part has its own content (a header with
text, a body with children) — the React-idiomatic way, and it fails loudly if you
mistype a part (
<Card.Hedaer>is an undefined component). - If you only need to style parts the component renders internally (parts with no
consumer content), reach for the
szsprop below — one prop that maps slot names to sz values.
The szs prop — style internal parts in one prop
Section titled “The szs prop — style internal parts in one prop”When a component renders its parts itself (an icon, a divider, a badge) there is
nothing for the consumer to compose — they just want to style those parts. szs
is a slot map: each key names a part, each value is a normal sz object. The build
transform compiles each value to its class string (keeping the key), safelists
and mangles the classes exactly like sz — and rewrites the whole attribute to
szsc (“szs, compiled”), the string-typed prop the component reads:
<Card szs={{ header: { bg: 'gray-100', font: 'bold' }, icon: { color: 'red-500' } }} />// compiles to:<Card szsc={{ header: "bg-gray-100 font-bold", icon: "text-red-500" }} />Why two names? A prop cannot be typed one way for the consumer who writes it (sz
objects, with autocompletion) and another way for the component that reads it
(plain strings) — so the build bridges two props: consumers write szs,
components read szsc. Declare both from one slot union with SzsProps,
and each slot forwards straight into a child className — no cast, no helper:
The two sides always pair up like this — the consumer writes szs, the
component reads szsc, and you never swap them:
import type { SzsProps } from '@csszyx/types';
// ── INSIDE the component: declare slots with SzsProps, READ `szsc` ──type CardProps = { title: string } & SzsProps<'header' | 'icon'>;
function Card({ title, szsc }: CardProps) { // Each szsc slot IS the compiled class string (or undefined when the // consumer didn't style that slot / the call site wasn't compiled). // Compose it with slot DEFAULTS right in `sz` — arrays are later-wins, // so the consumer's slot cleanly overrides the default per property: return ( <div> <header sz={[{ weight: 'semibold', text: 'base' }, szsc?.header]}>{title}</header> <svg sz={[{ size: 4 }, szsc?.icon]} /> </div> );}
// ── OUTSIDE, at the call site: the consumer WRITES `szs` (sz objects) ──<Card title="Invoice" szs={{ header: { bg: 'gray-100' }, icon: { color: 'red-500' } }} />;// The build rewrites that call to szsc={{ header: "bg-gray-100", icon: "text-red-500" }},// which is what `Card` receives. You author `szs`; you never write `szsc` by hand.Which prop where?
szsis the write side (consumer, sz objects);szscis the read side (component, compiled strings). A consumer never typesszsc; a component never receivesszs.
No defaults to merge? Forward the slot alone — className={szsc?.icon} and
sz={[szsc?.icon]} are both fine.
A complete component
Section titled “A complete component”Everything together — a Callout with three slots, its own defaults, and a
consumer that overrides two of them:
import type { SzsProps } from '@csszyx/types';import type { ReactNode } from 'react';
type CalloutProps = { title: string; children: ReactNode;} & SzsProps<'root' | 'title' | 'body'>;
function Callout({ title, children, szsc }: CalloutProps) { return ( // Each part: the component's DEFAULT sz first, the consumer's slot second. // The array is later-wins, so a consumer override beats the default on the // same property while defaults it didn't touch survive. <div sz={[{ p: 4, rounded: 'lg', bg: 'slate-50' }, szsc?.root]}> <p sz={[{ weight: 'semibold', text: 'sm', color: 'slate-900' }, szsc?.title]}> {title} </p> <div sz={[{ text: 'sm', color: 'slate-600' }, szsc?.body]}>{children}</div> </div> );}
// Consumer styles only the slots it cares about:<Callout title="Heads up" szs={{ title: { color: 'amber-700' }, root: { bg: 'amber-50' } }}> Your trial ends in 3 days.</Callout>;The consumer’s title.color (text-amber-700) overrides the default
text-slate-900; root.bg swaps the background; the body slot is untouched,
so it keeps the component’s defaults. No className plumbing, no cast.
What the compiler accepts and rejects
Section titled “What the compiler accepts and rejects”szs is a build-time feature: every slot value must be readable statically,
because its classes have to reach the Tailwind safelist before the browser ever
runs. The transform compiles what it can and leaves the rest untouched with a
dev warning — a rejected slot silently renders no classes, so it’s worth
knowing the line:
| Slot value | Result |
|---|---|
{ bg: 'gray-100', hover: { bg: 'gray-200' } } | ✅ compiled (nested variants are fine) |
'px-3 py-2' (raw class string) | ✅ passed through as-is |
size ? { p: 2 } : { p: 4 } (conditional) | ❌ whole attribute left unchanged, dev warning |
spreadVar / { ...base } (identifier / spread) | ❌ left unchanged, dev warning |
{ [key]: 4 } (computed key) | ❌ left unchanged, dev warning |
Need a slot to vary at runtime? Compute the variants with szv
and pass the result into sz on the child — szs itself stays static.
Other rules:
- Custom components only. A host element (
<div szs={…}>) has no slots — TypeScript rejects it and the transform leaves it unchanged. - Keys are identifiers (
header, not'header-x'or[computed]). - Slot classes are mangled in production like every other csszyx class, and
a slot the consumer omits is simply
undefined(never"[object Object]").
Migrating from szsClass (pre-0.11)
Section titled “Migrating from szsClass (pre-0.11)”Before 0.11 the slot prop stayed named szs and each slot was still typed as an
sz object, so components needed a szsClass() cast to reach a className. That
helper is removed — the prop now splits into szs (write) / szsc (read),
and szsc slots are already strings:
// Before (0.10.x):function Card({ szs }: { szs?: Szs<'title'> }) { return <h3 className={szcn('font-medium', szsClass(szs?.title))} />;}
// After (0.11+): read szsc, compose in sz, drop szcn + szsClass entirely:function Card({ szsc }: SzsProps<'title'>) { return <h3 sz={[{ weight: 'medium' }, szsc?.title]} />;}sz vs szs vs compound components
Section titled “sz vs szs vs compound components”szstyles THE element it sits on.szsstyles the parts a component renders internally. A component can take both —szfor its root,szsfor its slots.- Reach for
szswhen the consumer only needs to style internal parts they don’t own. - Reach for a compound component (
Card.Header, above) when the parts carry content the consumer provides. - Reach for
splitBoxwhen you’re routing ONE flatclassNamea caller already passed onto nested elements by box-model role, rather than exposing named slots.