Responsive sizing
Breakpoints, the responsiveSizing collection, spacing rules and the fluid %screen% cap.
On this page
Layout sizes (grid, gaps, container, section and article padding, type sizes, component sizes) live in the responsiveSizing token collection, with one mode per breakpoint. This page explains how those modes map to breakpoints, and how the spacing key of systhema.config turns their pixel values into CSS, fixed or fluid.
Breakpoints and screen sizesLink to this section
Each breakpoint has two numbers, both from the config token collection:
- Min width (
config.breakpoints): where the media query starts. Themd:andlg:modifiers and eachresponsiveSizingmode apply from here up. - Design width (
config.screenSizes): the viewport the breakpoint was designed at in Figma. Fluid spacing rules divide by it (%screen%).
| Breakpoint | Modifier | Min width | Design width | responsiveSizing mode |
|---|---|---|---|---|
sm | none | 0 | 390px | yes |
md | md: | 640px | 768px | yes |
lg | lg: | 1192px | 1192px | yes |
xl | xl: | 1366px | 1440px | no |
2xl | 2xl: | 1728px | 1920px | no |
3xl | 3xl: | 2048px | 2560px | no |
These are the default tokens, and they are not Tailwind's stock breakpoints: md: starts at 640px and lg: at 1192px. Breakpoints are mobile first, so sm is the base and needs no modifier; "small screens only" is md:hidden.
Every sizing token keeps one CSS variable name across breakpoints. The sm mode is written to :root and each larger mode to an @media (width >= <min width>) block, so --section-padding-y is 64px, then 60px from 640px, then 100px from 1192px. Above lg nothing changes unless you add modes for xl, 2xl and 3xl with customTokens.responsiveSizing (see The fluid cap). The per-breakpoint grid and container values are on Breakpoints, and every sizing token on Spacing.
Two variables tell CSS where it is: --breakpoint-current and --screen-current hold the active breakpoint's min width and design width as unitless numbers, for calculations such as the viewport units.
Spacing rulesLink to this section
spacing controls how spacing values are transformed into CSS. Single rule or array.
type SpacingRule = {
function?: string // template (default: '--spacing(%value% / 4)')
include?: string | string[] | false // paths to apply this rule to
exclude?: string | string[] | false // paths to skip
swap?: boolean // store raw value in :root, apply function in components
}Single rule:
const config: SysthemaConfig = {
spacing: {
function: '--spacing(%value% / 4)',
include: ['grid', 'gap', 'container'],
exclude: ['feature.mediaMinHeight'],
},
}Multiple rules:
const config: SysthemaConfig = {
spacing: [
{
function: '%value% / %screen% * 100vw',
include: ['grid', 'gap', 'container'],
},
{
function: '--spacing(%value% / 4)',
include: false,
exclude: '*borderWidth',
swap: true,
},
],
}Placeholders in the function template:
%value%— the numeric pixel value (withoutpx).%screen%— the design width of the breakpoint the value belongs to, i.e. itsconfig.screenSizesentry (sm390,md768,lg1192,xl1440,2xl1920,3xl2560). Note this is a different axis fromconfig.breakpoints(sm~0,md640,lg1192,xl1366,2xl1728,3xl2048), which is where the media query fires.
A template that is not wrapped in a CSS function (calc(, var(, --spacing( and the like) is evaluated after substitution: numbers, + - * /, unary minus and parentheses, rounded to four decimals, keeping the first unit ('%value% / %screen% * 100vw' at 16 and 1920 gives '0.8333vw'). Anything else it cannot evaluate, such as a variable or a division by zero, is emitted as the substituted string. The evaluator is a small parser, not eval.
Include/exclude patterns support * wildcards (*border* matches anything containing "border"). Matching is case-insensitive. Set to false to include/exclude nothing.
The fluid capLink to this section
A %screen% rule needs an xl/2xl/3xl cap.
Worked example, using the shipped lg tokens and screenSizes.lg = 1192:
| Token | lg value | Emitted under '%value% / %screen% * 100vw' |
|---|---|---|
container.width | 1036 | 86.9128vw |
article.paddingX | 88 | 7.3826vw |
Those ratios never stop scaling:
| Viewport | --container-width | Article body max-width |
|---|---|---|
| 1192px | 1036px | 860px |
| 1440px | 1252px | 1039px |
| 1920px | 1669px | 1385px |
| 2560px | 2225px | 1847px |
1847px of body copy is roughly four times a comfortable measure — the page reads as "nothing is constrained on a large display". @systhemaui/core warns about exactly this combination at build time in development (systhema doctor reports it too, as fluid-spacing-breakpoints), naming the uncapped breakpoints and the effective ratio.
The fix is per-breakpoint design values, not a clamp() on the formula — a cap encodes real decisions (column count, container margin, a step-changed article padding). This is the block the Payload and HTML templates ship verbatim; copy it and tune the numbers:
const config: SysthemaConfig = {
spacing: [
{
function: '%value% / %screen% * 100vw',
include: ['grid', 'gap', 'container', 'section', 'article', 'feature'],
exclude: ['feature.mediaMinHeight'],
},
],
customTokens: {
responsiveSizing: {
xl: {
grid: { default: { count: '24', gap: '20px', margin: '142px' } },
container: { width: '1156px', margin: '{grid.default.margin}' },
section: { paddingY: '96px' },
article: { paddingX: '147px' },
},
'2xl': {
grid: { default: { count: '24', gap: '24px', margin: '300px' } },
container: { width: '1320px', margin: '{grid.default.margin}' },
section: { paddingY: '120px' },
article: { paddingX: '168px' },
},
'3xl': {
grid: { default: { count: '24', gap: '24px', margin: '620px' } },
container: { width: '1320px', margin: '{grid.default.margin}' },
section: { paddingY: '120px' },
article: { paddingX: '168px' },
},
},
},
}Each added mode is transformed against its own config.screenSizes entry, so container.width: '1156px' at xl becomes 1156 / 1440 * 100vw = 80.2778vw inside the xl media block — the layout is re-based rather than merely clamped. A mode may be partial: only the variables you declare appear in that breakpoint's block, everything else keeps cascading from the mode below.
Declare every breakpoint above your narrowest mode, not just the widest one. Modes are emitted one media block each, so a breakpoint you skip inherits the nearest lower mode's ratios for its whole band. Declaring only 3xl, for example, leaves xl and 2xl scaling the lg ratios up to 2048px and then jumping when 3xl finally re-bases them — bounded, unlike the top of the range, but never a width anything was designed for. Both the build-time warning and systhema doctor name every configured breakpoint above your lowest mode that has no mode of its own. Breakpoints below your lowest mode are never reported: that mode is emitted at :root, so it is the base of the cascade and has nothing to re-base against.
swapLink to this section
swap: true moves where the spacing function is applied. Without it the function is baked into the :root value; with it the :root value is the raw number and the function wraps the var() reference at each point of use in the component CSS. The rendered result is the same — but what a token READER sees is not.
That is why a single token path has no single value shape. Which one a project gets is decided by its own spacing config, not by anything Systhema ships:
| The rule that claims the path | What getRow() / clientTokenValue() returns |
|---|---|
The default rule (or no spacing key at all) | '--spacing(88.75 / 4)' |
function: '%value% / %screen% * 100vw' | '11.0938vw' |
function: 'calc(%value% / %screen% * 100vw)' | 'calc(88.75 / 1920 * 100vw)' — kept verbatim |
Any rule with swap: true | '88.75' — a bare numeric |
| No rule claims it (excluded, or unmatched) | '88.75px' |
The calc() row is not a formatting variant of the one above it: a template already wrapped in a CSS function is never evaluated, so the value stays arithmetic rather than becoming a length. A template carrying a CSS comment keeps it too ('3.1250vw /* 60px */').
A row can also mix these: nothing requires a rule to claim a path at every breakpoint, so {sm: '0px', md: '0px', lg: '11.0938vw'} is an ordinary responsive design.
So: ask a token value a question rather than converting it, unless you genuinely need pixels. isPositiveTokenLength(value) answers "is this bigger than zero?" for every shape above and needs no viewport, so it gives the same answer on the server, in a build step and in the browser. Converting first — resolveTokenPx(value, 0) > 0 — reads false server-side for the vw shape, because a viewport unit has no pixel answer without a window and collapses into the fallback. See Picking the right reader.
One more caveat when reading under swap: true: the :root value is not the effective computed value, and on its own it is not even a valid CSS length. --article-padding-x: 88.75 is a bare number waiting to be wrapped; what the browser lays out is whatever the rule's function template makes of it at the point of use — 88.75px under --spacing(%value% / 4), but 7.45vw under %value% / %screen% * 100vw. Harmless for a sign test (every template scales the number by a positive factor, so the sign is invariant), misleading if you expected the final value.