---
title: "Responsive sizing"
description: "Breakpoints, the responsiveSizing collection, spacing rules and the fluid %screen% cap."
requested_language: hu
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/hu/next/concepts/responsive-sizing
version: unreleased (main)
docs_index: https://docs.systhema.app/hu/next/llms.txt
---
> This page isn't translated yet. Showing English.


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 sizes

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%`).

| 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-fluid-cap)). The per-breakpoint grid and container values are on [Breakpoints](https://docs.systhema.app/hu/next/reference/tokens/breakpoints.md), and every sizing token on [Spacing](https://docs.systhema.app/hu/next/reference/tokens/spacing.md).

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](https://docs.systhema.app/hu/next/styling/viewport-units.md).

## Spacing rules

`spacing` controls how spacing values are transformed into CSS. Single rule or array.

```ts
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:

```ts
const config: SysthemaConfig = {
  spacing: {
    function: '--spacing(%value% / 4)',
    include: ['grid', 'gap', 'container'],
    exclude: ['feature.mediaMinHeight'],
  },
}
```

Multiple rules:

```ts
const config: SysthemaConfig = {
  spacing: [
    {
      function: '%value% / %screen% * 100vw',
      include: ['grid', 'gap', 'container'],
    },
    {
      function: '--spacing(%value% / 4)',
      include: false,
      exclude: '*borderWidth',
      swap: true,
    },
  ],
}
```

> [!IMPORTANT]
> **The second rule's `swap: true` changes the SHAPE of every value it claims.** Under swap the `:root` variable holds the **raw numeric** (`--article-padding-x: 88.75`) and the spacing function is applied around the `var()` reference in the component CSS instead — so reading that token back gives you `'88.75'`, not `'--spacing(88.75 / 4)'` and not `'88.75px'`. Copy this array and you get bare-numeric rows for whatever the rule claims; a project with no `spacing` key gets `--spacing()` rows for the same paths. Both are correct, and code that reads token values must handle both. See [`swap`](#swap) below.

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 cap

A `%screen%` rule needs an `xl`/`2xl`/`3xl` cap.

> [!WARNING]
> **A fluid rule turns each breakpoint's values into a ratio, and a breakpoint with no `responsiveSizing` mode of its own never re-bases it — above the TOPMOST mode that goes on unbounded.** Systhema ships `config.breakpoints` and `config.screenSizes` entries for `xl`, `2xl` and `3xl`, but `responsiveSizing` token files only for `sm`, `md` and `lg` — so nothing re-declares the layout variables above 1192px. **Any project using a `%screen%` rule must declare `customTokens.responsiveSizing.{xl,2xl,3xl}` itself.** Projects on the default `--spacing(%value% / 4)` rule emit fixed px and are unaffected.

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:

```ts
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.

## `swap`

`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](https://docs.systhema.app/hu/next/reference/core-client.md#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.

## Related

- [Breakpoints and ranges](https://docs.systhema.app/hu/next/styling/breakpoints.md)
- [Viewport-relative spacing](https://docs.systhema.app/hu/next/styling/viewport-units.md)
- [Custom tokens](https://docs.systhema.app/hu/next/design/custom-tokens.md)
- [systhema.config reference](https://docs.systhema.app/hu/next/reference/config.md#spacing)
