Skip to content

Box Model Routing

You hand a component one className. 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.

import { splitBox } from '@csszyx/runtime';
const { outer, inner } = splitBox('m-4 px-2 md:flex');
// outer → "m-4" (margin: belongs on the frame)
// inner → "px-2 md:flex" (padding + layout: belong on the content)

A flat className is fine when a component is a single <div>. The moment it wraps its children, the caller’s intent splits in two:

// The caller writes ONE string…
<Panel className="m-4 rounded-xl shadow-lg overflow-hidden p-6" />
// …but <Panel> renders TWO elements, and the styles belong to different ones:
function Panel({ className, children }) {
return (
<div className={/* m-4 rounded-xl shadow-lg overflow-hidden ??? */}>
<div className={/* p-6 ??? */}>{children}</div>
</div>
);
}

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 ─ margin · position · sizing · background · border · shadow · overflow-hidden · cursor · flex/grid item
┌──────────────────────── the border line ────────────────────────┐
│ │
│ inner ─ padding · overflow-auto · display · flex/grid container · gap · text · divide
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ your content │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
  • 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.

splitBox is framework-agnostic — it’s a string in, two strings out. Use it in React, Vue, Svelte, Solid, an edge function, or plain HTML.

import { splitBox } from '@csszyx/runtime';
function Panel({ className = '', children }) {
const { outer, inner } = splitBox(className);
return (
<div className={outer}>
<div className={inner}>{children}</div>
</div>
);
}
<Panel className="m-4 rounded-xl shadow-lg overflow-hidden p-6" />;
// frame → "m-4 rounded-xl shadow-lg overflow-hidden"
// content → "p-6"

These are the defaults. Every one of them is overridable (next section).

BucketCategoriesExamples
outermargin · position · sizing · bg · border · ring (inset-ring too) · shadow · transform · visibility · clipping (overflow-hidden overflow-clip) · pointer (cursor-* select-* pointer-events-* will-change-*) · scroll margin (scroll-m-*) · snap position (snap-start snap-center snap-always) · align-* · flex/grid item (grow shrink basis order self col-span row-span)m-4 -mt-2 absolute inset-0 w-full bg-white rounded-xl border shadow-lg rotate-3 invisible z-10 overflow-hidden cursor-pointer
innerpadding · scrolling (overflow-auto overflow-scroll) · display · flex/grid container (flex-col flex-wrap items-* justify-* grid-cols-*) · gap · text · divide-* · perspective-* · transform-3d · form affordance (resize-* appearance-* field-sizing-*)p-6 overflow-y-auto flex grid gap-2 text-sm underline divide-y
bothtransition-* duration-* ease-* delay-*transition-colors duration-300

A mixed string sorts itself in one pass:

splitBox('w-full flex bg-white overflow-hidden overflow-y-auto invisible p-2 m-2');
// outer → "w-full bg-white overflow-hidden invisible m-2"
// inner → "flex overflow-y-auto p-2"

splitBox classifies the base utility, so the messy parts of a real className come along for the ride untouched:

// Variant prefixes are preserved; the base is what gets classified.
splitBox('md:px-2 hover:shadow-lg');
// outer → "hover:shadow-lg" inner → "md:px-2"
// Stacked + arbitrary variants never get split at the wrong colon.
splitBox('@max-[600px]:p-4 [&:hover]:m-2 aria-[sort=asc]:flex');
// outer → "[&:hover]:m-2"
// inner → "@max-[600px]:p-4 aria-[sort=asc]:flex"
// 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.
splitBox('overflow-hidden p-4', { inner: ['overflow'] });
// outer → "" inner → "overflow-hidden p-4"
// Pin the transition to one node, instead of declaring it on both.
splitBox('transition-colors', { outer: ['transition'] });
// outer → "transition-colors" inner → ""

A BoxSelector is whatever is most convenient:

'outer' | 'inner' // a box-role
'content' // alias for inner
'overflow' | 'bg' | 'text' // a category
'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

Section titled “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:

classify(token) // → { role, category, confidence, property? } | undefined
has(classes, selector) // → boolean ("is there an overflow class?")
pick(classes, selector) // → string (keep only matching tokens)
omit(classes, selector) // → string (drop matching tokens)
classify('px-2'); // { role: 'inner', category: 'padding' }
classify('m-4'); // { role: 'outer', category: 'margin' }
classify('absolute'); // { role: 'outer', category: 'position' }
classify('totally-custom');// undefined (not a csszyx utility)

They see through variants and markers too: classify('md:px-2') and classify('px-2!') both report padding.

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:

classify('text-sm'); // { role: 'inner', category: 'text', confidence: 'exact', property: 'size' }
classify('p-4'); // { role: 'inner', category: 'padding', confidence: 'prefix' }
classify('tab-items-wrapper'); // { role: 'inner', category: 'text', confidence: 'prefix' }

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:

classify('text-red-500'); // { role: 'inner', category: 'text', property: 'color' }
classify('text-sm'); // { role: 'inner', category: 'text', property: 'size' }
classify('font-bold'); // { role: 'inner', category: 'text', property: 'weight' }
classify('bg-cover'); // { role: 'outer', category: 'bg', property: 'size' }

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:

pick('text-red-500 text-sm font-bold', 'text:color'); // 'text-red-500'
omit('text-red-500 text-sm', 'text:color'); // 'text-sm'
pick('font-bold font-sans', 'font:weight'); // 'font-bold'
has('bg-cover', 'outer:size'); // true

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.

The supported category names are:

accent, alignment, backdrop, bg, blend, border, color-scheme, columns, display, divide, filter, flex, fragmentation, gap, gradient, grid, interaction, list, margin, mask, object, opacity, outline, overflow, overscroll, padding, placeholder, position, ring, rounded, scope, scroll, shadow, sizing, snap, space, svg, table, text, touch, transform, transition, visibility.

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:

import { classify, has, omit, pick, splitBox } from '@csszyx/runtime/split';

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:

import { splitBox } from '@csszyx/runtime';
function ScrollArea({ className = '', children }) {
const { outer, inner } = splitBox(className);
return (
<div className={`${outer} flex flex-col min-h-0 overflow-hidden`}>
<div className={`${inner} flex-1 min-h-0 overflow-y-auto`}>{children}</div>
</div>
);
}
<ScrollArea className="rounded-xl p-4 h-64 border" />;
// frame → "rounded-xl h-64 border flex flex-col min-h-0 overflow-hidden"
// content → "p-4 flex-1 min-h-0 overflow-y-auto"

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:

pick('m-4 p-2 bg-white', 'outer'); // "m-4 bg-white"
omit('p-2 overflow-y-auto flex', 'overflow'); // "p-2 flex"

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:
splitBoxSz({ gap: 2, hover: { px: 1 }, md: { m: 4 } });
// → { outer: { md: { m: 4 } }, inner: { gap: 2, hover: { px: 1 } } }
  • 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:
splitBoxSz(
{ grow: 2, self: 'center', order: 'first' },
{ inner: ['grow', 'self', 'order'] },
);
// → { outer: {}, inner: { grow: 2, self: 'center', order: 'first' } }
  • Pin a whole variant container by naming its literal sz key. This keeps all its nested properties on one node, even when their defaults differ:
splitBoxSz({ hover: { p: 2, m: 4 } }, { outer: ['hover'] });
// → { outer: { hover: { p: 2, m: 4 } }, inner: {} }

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.
splitBoxSz({ overflow: 'hidden', p: 4, transition: 'colors' });
// → { outer: { overflow: 'hidden', transition: 'colors' },
// inner: { p: 4, transition: 'colors' } }

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:

import { splitBoxSz } from '@csszyx/runtime';
function Box({ sz, children }) {
const { outer, inner } = splitBoxSz(sz);
return <div sz={outer}><div sz={inner}>{children}</div></div>;
}

Zero runtime weight

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.