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 · transform · visibility
┌──────────────────────── the border line ────────────────────────┐
│ │
│ inner ─ padding · overflow · display · gap · layout · text · interactivity
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ your content │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
  • outer = border-outward. How the box sits in the world: m-4, absolute, w-full, bg-white, rounded-xl, shadow-lg, rotate-3, invisible.
  • inner = border-inward. How the box treats its contents: p-6, overflow-hidden, flex, gap-2, text-sm, cursor-pointer.

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"
// content → "overflow-hidden p-6"

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

BucketCategoriesExamples
outermargin · position · sizing · background · border · shadow · transform · visibilitym-4 -mt-2 absolute inset-0 w-full bg-white rounded-xl border shadow-lg rotate-3 invisible z-10
innerpadding · overflow · display · layout · gap · text · paint-inside · interactivityp-6 overflow-hidden flex grid gap-2 text-sm underline ring-2 cursor-pointer

A mixed string sorts itself in one pass:

splitBox('w-full flex bg-white overflow-hidden invisible p-2 m-2');
// outer → "w-full bg-white invisible m-2"
// inner → "flex overflow-hidden 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:

Lossless

Every token lands in exactly one bucket. Concatenate outer + inner back together and you get your input — no token dropped, none duplicated.

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

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"
// Keep the clip on the frame.
splitBox('overflow-hidden p-4', { outer: ['overflow'] });
// outer → "overflow-hidden" inner → "p-4"

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)

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 } | 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 clip stays on the frame; the scroller is derived from it — no string matching, no className.includes('overflow-hidden'):

import { splitBox, has } from '@csszyx/runtime';
function ScrollArea({ className = '', children }) {
// Keep overflow on the frame so the rounded clip is correct.
const { outer, inner } = splitBox(className, { outer: ['overflow'] });
// YOUR rule: a clipping frame implies a scrolling child.
const scroll = has(outer, { overflow: 'hidden' }) ? 'overflow-y-auto' : '';
return (
<div className={outer}>
<div className={`${inner} ${scroll}`.trim()}>{children}</div>
</div>
);
}
<ScrollArea className="overflow-hidden rounded-xl p-4 h-64" />;
// frame → "overflow-hidden rounded-xl h-64"
// content → "p-4 overflow-y-auto"

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 behave exactly like SplitBoxOptions. Flex-item utilities (grow/shrink/self/order) default to the inner content; force them onto the frame when the frame is the flex item:
splitBoxSz(
{ grow: 2, self: 'center', order: 'first' },
{ outer: ['grow', 'self', 'order'] },
);
// → { outer: { grow: 2, self: 'center', order: 'first' }, inner: {} }

The toolkit has sz-object twins as well — classifySzKey(key), hasSz, pickSz, omitSz — reading the same generated truth keyed by sz prop instead of class token. 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.

Lossless & predictable

Every token routed exactly once, 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.