Skip to content

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.

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

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

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.

  • 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 szs prop 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? szs is the write side (consumer, sz objects); szsc is the read side (component, compiled strings). A consumer never types szsc; a component never receives szs.

No defaults to merge? Forward the slot alone — className={szsc?.icon} and sz={[szsc?.icon]} are both fine.

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.

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 valueResult
{ 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]").

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 styles THE element it sits on. szs styles the parts a component renders internally. A component can take both — sz for its root, szs for its slots.
  • Reach for szs when 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 splitBox when you’re routing ONE flat className a caller already passed onto nested elements by box-model role, rather than exposing named slots.