You hand a component oneclassName. It renders several nested elements.
Which styles go where?
The margin belongs on the outer frame. The padding belongs on the inner content.
The rounded corners and the shadow stay outside; the scroll and the text color go
inside. Get it wrong and your padding double-counts, your shadow clips, your
border lands on the wrong edge.
splitBox answers that question for you — at the CSS box-model border line —
and it does it with pure string functions: no React, no DOM, no runtime style
engine. The class-token → box-role map is generated from the compiler’s own
property tables, so it can never drift out of sync with what csszyx emits.
Put everything on the outer node and the padding pushes the rounded clip out of
place. Put everything on the inner node and the margin collapses. A slot recipe
or a cva-style variant can’t fix this — they generate classes, they don’t
re-route a string the caller already built. Only a runtime partition can.
CSS already draws the line for you. Every box property acts on one side of the
element’s border — outward (the box’s relationship to its neighbors) or inward
(the box’s relationship to its contents).
outer = border-outward. How the box sits in the world and how it paints
itself: m-4, absolute, w-full, bg-white, rounded-xl, shadow-lg,
rotate-3, invisible, overflow-hidden, cursor-pointer — and how it sits
among its siblings: grow, order-2, self-center, col-span-2,
snap-start.
inner = border-inward. How the box treats its contents: p-6,
overflow-y-auto, flex, flex-col, items-center, gap-2, text-sm,
divide-y.
overflow is the one property that lands on both sides, because it is two
properties wearing one name: overflow-hidden and overflow-clip describe how
the box is clipped by its own frame, so they go outer, while overflow-auto
and overflow-scroll ask the box to scroll its children, so they go inner.
That is what lets a scroll frame work with no override at all.
splitBox walks the className token by token, asks the generated map “which side
of the border does this act on?”, and drops each token in the matching bucket.
// Negative (-) and important (!) markers are seen through.
splitBox('-mt-4 px-2! !flex');
// outer → "-mt-4" inner → "px-2! !flex"
Three guarantees make it safe to drop into a forwarding component:
No loss
Every token you pass comes back. Each lands in exactly one bucket, except the
timing group — transition-*, duration-*, ease-*, delay-* — which is
declared on both, because a transition only runs on the element whose
property changes and the state that changes it (hover:bg-* on the frame,
hover:text-* on the content) can sit on either side.
Predictable fallback
A token csszyx doesn’t own (a third-party or arbitrary class like
[mask-type:luminance]) goes to outer by default. Flip it with
fallback: 'inner'.
Mangle-safe
Every token is decoded through the runtime mangle registry before it is
classified, so a production build where the DOM carries q3 for w-full
routes exactly as development does. The buckets still hold the raw tokens — the
stylesheet is mangled, so only those match a rule.
Speaks the compiler's vocabulary
It reads the compiler’s tables, so it understands the same class vocabulary the
compiler emits — variants, important, negative, value-keyed display/position.
The defaults follow the box model, but your design owns the final call. Pass
outer / inner selectors to force a category, a class-prefix, or a precise
{category: value} pair to the side you want. inner wins ties.
// Send backgrounds to the inner node for this component.
splitBox('bg-white m-2',{ inner:['bg']});
// outer → "m-2" inner → "bg-white"
// Send the clip to the content instead — the frame keeps its corners square.
'px'|'bg'// a class-prefix (matches px-2, bg-red-500, …)
{ overflow: 'hidden'}// a category + value pair (most precise)
'text:color'// any of the above, qualified by a CSS property
The splitBox placement lists also accept a literal base class name. Use
this to place a custom class whose properties the toolkit cannot classify:
splitBox('card p-4',{ inner:['card']});
// outer → "" inner → "card p-4"
An unknown name matches only that whole base name: card does not match
card-lg. On a mangled build, use the authored name; the toolkit decodes the
token before comparing. An explicit placement is quiet even when it chooses
the same node as fallback.
Placement matches the base utility across variants. Write
{ outer: ['hidden'] } to place hidden, md:hidden and hover:hidden
on the frame. { outer: ['md:hidden'] } matches nothing and warns in
development; a placement list cannot select just one variant, and the same
goes for the ! and - markers — write mt-4, not -mt-4.
Literal placement is specific to splitBox’s outer and inner lists.
has, pick, omit and their sz-object query siblings still require a
recognised selector. splitBoxSz placement lists instead name literal sz
keys, including a whole variant container (see below).
The toolkit: csszyx owns the truth, you own the rule
Routing is one job. The harder one is cross-element dependencies: “if the
frame clips its overflow, the scroller inside must actually scroll.” That rule is
yours — it depends on your component. But to express it, you need to read
the classes, and that’s where projects usually hardcode a brittle list of
Tailwind strings.
csszyx owns the truth (a class’s box-role + category); you own the rule.
Four pure helpers expose the truth — no rule-DSL, no hardcoded vocabulary:
The table matches a class by its PREFIX and holds no knowledge of which values
a prefix accepts, so your own tab-items-wrapper comes back looking exactly
like a real text-sm:
exact means the whole name is in the table — value-keyed sugar (block), a
boolean shorthand (truncate), one closed value of a prefixed key
(overflow-hidden), a scope marker, or a prefix that is itself a whole utility
(flex). prefix means only the part before the value matched and the
classifier took the value on trust: p-4 and a made-up class land together.
So confidence is not a verdict on whether the class is real — the runtime
cannot know that, because Tailwind is a build tool and is not present in the
browser. It says which of the two ways the classifier reached its answer, so a
caller that wants to be strict can be, and the toolkit never claims a certainty
it does not have. classifySzKey always answers exact: it looks up an sz key
whole, with no prefix to guess at.
Deciding whether a prefix answer names a real utility needs the project’s own
design system, which lives at build time. csszyx check reports exactly that
case — a class the toolkit reads confidently that your Tailwind does not serve.
Some prefixes cover more than one CSS property: text-red-500 is a colour and
text-sm is a font size, yet both are the text category. For those, classify
also names the property — the same answer szcn merges by, so the two halves of
csszyx never disagree about a token:
Token names your app declares in its Tailwind @theme are read the same way a
built-in one is, because the build registers them (see
the theme group registry). With
--color-brand and --text-huge in the theme, classify('text-brand') reports
color and classify('text-huge') reports size.
property is absent — never null, never empty — when the prefix means
exactly one property (p-4 is padding whatever the value) and when the value
does not confidently name one (border-t-2 sets a width on one side; szcn keeps
both classes there rather than guess, and classify says nothing rather than
guess). It never affects which node a token is routed to: it is extra
information about a class, not a vote on where the class goes.
Any selector can be qualified with a property, so a rule can address text
colours without touching text sizes:
The half before the colon is an ordinary selector — a role, a category or a
class prefix — so 'text:color' and 'font:weight' each read the way the class
itself reads. The half after it must be one of the eighteen properties csszyx
tells apart; any other word is reported in development, because
pick(cls, 'text:colour') matching nothing silently is exactly the kind of
mistake that costs an afternoon.
property is additive: an equality assertion on classify’s whole result in
your own tests needs the new field where it applies.
scope identifies the group and peer markers, including named markers
such as group/item. Both default to the outer node: group establishes
the ancestor for descendant variants, while peer keeps the marker in
position among the frame’s siblings. classify('group') returns
{ role: 'outer', category: 'scope' }.
The consumers of those markers differ too. A group-hover:p-4 reaches its
target through descent, and the content node is a descendant, so it routes
by its base like any other class. A peer-hover:p-4 reaches its target as a
sibling — and the content node is a child of the frame, a sibling of
nothing you wrote — so every peer-* class stays on the frame whatever its
base, with a development warning when that moved it. To reach the content
from there, write the child variant on the frame: peer-hover:[&>*]:p-4.
The expanded Tailwind vocabulary changes results for classes that previously
used the fallback. placeholder-* now classifies as placeholder and goes
inner; start-* and end-* classify as position and go outer. group and
peer, including named markers, classify as scope and go outer even with
fallback: 'inner'. Category queries with pick and omit now recognise
these classes too.
Review components that relied on the old fallback placement. Use an explicit
outer or inner selector where your component needs to keep that placement,
and update assertions that expect these tokens to be unclassified.
classify, has, pick, omit and splitBox read a className string.
Their sz-object siblings (splitBoxSz and friends, further down this page) read
an sz object, which needs the compiler’s key vocabulary. If your project only
ever writes Tailwind strings, import the half you use:
Under a bundler that tree-shakes, this saves almost nothing — 7.2 KB gz
against the main entry’s 7.3 KB, because the main entry already shakes.
It matters under require(), where nothing shakes at all: 25.2 KB gz
against 31.2 KB. About 1.9 KB of either number is the value
classifier property reads — the tables szcn already ships, so an app that
uses szcn pays nothing extra for it.
The vocabulary is atomic utilities — a class whose name states one feature.
p-4 is padding. bg-red-500 is a background. end-2 is a logical inset.
A custom @utility that declares several properties at once is deliberately
not in scope:
@utility card {
margin: 0.5rem; /* outer */
padding: 1rem; /* inner */
background: white;
}
classify('card') returns undefined, pick(classes, 'padding') does not
return it, omit(classes, 'padding') keeps it, and splitBox leaves it on the
fallback node. That is a decision, not a gap. There is no correct answer: send
card outward and the content loses its padding; send it inward and the margin
moves inside the frame; send it to both and the margin applies twice while the
background paints twice. The only way to be right would be to rewrite your CSS
into per-property fragments, which csszyx will not do behind your back.
Choose the node explicitly with a literal placement such as
{ inner: ['card'] }, as shown in Override the defaults.
The class stays intact on that node; its CSS declarations are never split.
The frame takes the height bound and the rounded clip; the content scrolls
inside it. Both clips land where they belong on their own, so the split needs no
override — what the component adds is the plumbing that makes a child inherit
its parent’s height:
min-h-0 on both nodes is the part that is easy to miss: a flex item’s default
min-height: auto refuses to shrink below its content, so without it the
content grows past the frame and nothing scrolls. Swapping h-64 for max-h-64
makes the same component shrink to fit a short child.
pick and omit round out the kit when you need to move or strip a category:
splitBox partitions a className string. But a component that stays
sz-native — building styles with szv so the compiler
statically safelists every variant — holds an sz object, not a string.
Bridging through a className to reach splitBox throws that auto-safelisting
away.
splitBoxSz partitions the sz object directly, into { outer, inner } sz
objects you hand straight to nested sz props:
import{ splitBoxSz }from'@csszyx/runtime';
splitBoxSz({ m:4, px:2});
// → { outer: { m: 4 }, inner: { px: 2 } }
It routes each key to the same side its emitted class would land on, so
splitBoxSz(x) agrees with splitBox(compile(x)) by construction — a test gates
the parity. Everything splitBox does, the sz version does on keys, not tokens:
Variants & responsive route by the property inside them, and split across
buckets when they disagree:
Arrays flatten (deep-merged last-write-wins; null/false drop), so a
conditional szv composition partitions in one call:
splitBoxSz([base, isLoading &&{ opacity:50}]);
Overrides can select roles, categories or sz keys. Flex and grid item
utilities (grow/shrink/basis/order/self/col-span…) describe how the
box sits among its siblings, so they go to the frame; the container
utilities (flex-col, items-*, gap-*, grid-cols-*) describe its contents
and stay inner. Force an item utility back onto the content when the frame is
itself the flex container laying out that one child:
A selector such as '[&:hover]' works the same way for that literal key.
inner wins if both lists name it. This is sz-key placement; class-string
placement still matches base utilities across variants.
overflow routes by its value, the way it does on the class side:
{ overflow: 'hidden' } clips the frame and goes outer, { overflow: 'auto' }
scrolls the content and stays inner. The timing group (transition,
duration, ease, delay) is written to both objects.
The toolkit has sz-object twins as well — classifySzKey(key, value?), hasSz,
pickSz, omitSz — reading the same generated truth keyed by sz prop instead of
class token. Pass the value to classifySzKey for a key whose side depends on it
(classifySzKey('overflow', 'hidden') → outer). The whole component stays sz-native end to end:
Pure string functions. No React, no DOM, no style engine — tree-shakeable and
safe in server components, edge runtimes, and workers.
Framework-agnostic
String in, strings out. The same call works in React, Vue, Svelte, Solid, and
vanilla JS.
Generated, never drifts
The box-role map is generated from the compiler’s property tables and gated by a
test — it always matches what csszyx emits.
Nothing lost, nothing guessed
Every token comes back, variants preserved, unknown tokens to a configurable
fallback. Safe in a forwarding component.
This is the part of a styling system most libraries leave to you and a regex:
turning a flat string into correctly-placed styles across a real component tree.
csszyx ships it as a primitive — because it already knows what every class does.
Runtime Helpers reference — full signatures for
splitBox / splitBoxSz, classify / classifySzKey, and the has / pick
/ omit toolkit (string and sz-object forms).
Sz Props Basics — where the classNames come from in the
first place.
Reusing Styles — share sz objects before they ever
become a string.