Docs
Systhema Design (opens in new tab)
Unreleased

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. The md: and lg: modifiers and each responsiveSizing mode apply from here up.
  • Design width (config.screenSizes): the viewport the breakpoint was designed at in Figma. Fluid spacing rules divide by it (%screen%).
BreakpointModifierMin widthDesign widthresponsiveSizing mode
smnone0390pxyes
mdmd:640px768pxyes
lglg:1192px1192pxyes
xlxl:1366px1440pxno
2xl2xl:1728px1920pxno
3xl3xl:2048px2560pxno

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 (without px).
  • %screen% — the design width of the breakpoint the value belongs to, i.e. its config.screenSizes entry (sm 390, md 768, lg 1192, xl 1440, 2xl 1920, 3xl 2560). Note this is a different axis from config.breakpoints (sm ~0, md 640, lg 1192, xl 1366, 2xl 1728, 3xl 2048), 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:

Tokenlg valueEmitted under '%value% / %screen% * 100vw'
container.width103686.9128vw
article.paddingX887.3826vw

Those ratios never stop scaling:

Viewport--container-widthArticle body max-width
1192px1036px860px
1440px1252px1039px
1920px1669px1385px
2560px2225px1847px

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