---
title: "Responsive modes"
description: "Four named modes, the rule that a wider screen shows more of the story rather than a bigger version of it, and why a component queries its container."
url: "https://opsinjs.pensievelabs.org/foundations/space/responsive-modes"
source: "https://opsinjs.pensievelabs.org/foundations/space/responsive-modes.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["breakpoints", "mobile", "tablet", "wide", "density"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#overview]

Most responsive design is a phone layout that has been allowed to stretch. Type
gets larger, padding gets larger, a card that was 340 points wide becomes 900
points wide with the same three lines of text in it, and the result is a tablet
experience that is worse than the phone one it came from. It shows the same
information, further apart, with more work for the eye to travel.

opsinjs states the rule the other way round:

> **On a wider screen nothing gets bigger. You get more of the story visible at
> once.**

Body type stays at its reading size. Touch targets stay at their floor. Radii
stay on their rung. What changes is how much is simultaneously visible: a reading
and its history side by side instead of one behind the other; a list and the
selected item's detail at the same time; a sheet that was a temporary overlay
becoming a permanent pane. The reader is not given a magnified phone. They are
given the parts of the story that would not previously fit.

The second half of the page is the mechanism, and it is the part that is easy to
get wrong: &#x2A;*components query their container, not the viewport.**

## How it works [#how-it-works]

**Four modes**, named for how much room a container has rather than for a class
of device, because a "tablet" breakpoint tells you nothing about a component
sitting in a 320-point sidebar on a 1400-point screen:

| Mode       | Shape                                     | What appears                                       |
| ---------- | ----------------------------------------- | -------------------------------------------------- |
| `compact`  | A single column; the shell is the screen  | One thing at a time; detail arrives as a sheet     |
| `medium`   | A single wider column, or two narrow ones | Related items pair up; the sheet gets a detent     |
| `expanded` | Two regions side by side                  | List and detail together; the sheet becomes a pane |
| `wide`     | Two regions plus context                  | History, trend or provenance alongside the detail  |

The thresholds live in `tokens/space.json` and are published in the generated
table rather than restated here, so there is one copy of them.

**Modes are resolved per container.** Each region of the
[shell](../layout.mdx) establishes a size container. A component asks its own
container how much room it has, and gets an answer about the space it is actually
in. A metric tile in a narrow sidebar on a large desktop is in `compact`, and it
should be. The viewport is irrelevant to it.

```css
/* The shell's regions establish the containers. */
.region {
  container-type: inline-size;
  container-name: opsin-region;
}

/* A component responds to the space it is in, not to the screen it is on. */
@container opsin-region (min-width: 30rem) {
  .metric-tile {
    grid-template-columns: auto 1fr;
  }
}
```

**The one exception is the shell itself.** There is no container above the root
element, so the shell's own mode comes from a viewport media query. Every
component inside it uses container queries, without exception. That boundary is
the whole rule: the shell is allowed to know about the screen; components are
not.

**What is allowed to change with mode:**

* the number of columns and regions;
* whether a surface is transient (sheet, dialog) or persistent (pane, inline);
* how much history, context or provenance is shown by default;
* the outer gutter, which grows to keep the measure in range.

**What is not allowed to change with mode:**

* the body reading size;
* the touch-target floor;
* radius rungs;
* the line-length cap;
* the meaning, order or wording of anything.

That last one deserves its own sentence. A screen must not say something
different, or say it in a different order, because the window is wider. Somebody
comparing what they see on a phone with what a family member sees on a tablet
should be looking at the same information.

## Using it [#using-it]

**Design `compact` first, then ask what would not fit.** The things that did not
fit are the story you now have room for. Working the other way round produces a
desktop layout with a phone layout carved out of it, and the carving is always
where the important things were.

<DoDont>
  <DoDont.Do>
    At `expanded`, the results list stays on the left at its compact width and
    the selected reading opens in a pane on the right. It is the same reading
    component at the same size, now permanently visible next to its list.
  </DoDont.Do>

  <DoDont.Dont>
    At `expanded`, the single results list stretches to fill the window, so each
    row is a metre of whitespace with a number at either end and the eye has to
    travel the full width to connect a label to its value.
  </DoDont.Dont>
</DoDont>

**Never use the viewport inside a component.** A component that reads the viewport
is a component that breaks the moment it is placed anywhere unexpected: in a
pane, in a sidebar, in a preview iframe on this very site, or in somebody else's
grid.

<DoDont>
  <DoDont.Do>
    `@container opsin-region (min-width: 30rem)` stacks or unstacks the tile
    based on the room it actually has, and it is correct in every context it is
    ever dropped into.
  </DoDont.Do>

  <DoDont.Dont>
    `@media (min-width: 768px)` inside a tile. In a 300-point pane on a wide
    screen, the tile lays itself out as though it had 768 points, overflows, and
    nobody who built the pane knows why.
  </DoDont.Dont>
</DoDont>

**Cap the measure, always.** Prose stops widening at a readable line length no
matter how much room there is. Beyond that point the gutter grows or another
region appears; the paragraph does not get longer lines.

**Test at the awkward widths.** The failures live in the gaps between modes and
in containers that are narrow inside wide viewports: a 320-point pane, a split
view, a preview frame. Every `<ComponentPreview>` on this site can be resized for
exactly this reason.

**Do not ship a density preference.** Some systems let readers choose a compact
mode. In a patient-facing health product that is a setting that lets somebody
make their own touch targets too small; the density controls in these previews
exist so you can see the effect, not so the product can offer it.

## Tokens [#tokens]

The four mode thresholds, the container names, the gutter values per mode and the
measure cap are generated from `tokens/space.json` by
`scripts/build-tokens.mts`.

<TokenTable scope="space" />

## Accessibility impact [#accessibility-impact]

* **WCAG 2.2 SC 1.4.10 *Reflow* requires content to work at 320 CSS pixels of
  width without two-dimensional scrolling.** `compact` is not a phone mode that
  happens to satisfy this; it is the mode a desktop browser enters at 400% zoom,
  which is precisely how that criterion is tested.
* **Container queries make zoom behave.** At high zoom the viewport reports a
  small width, but so does every container. A component that queries its
  container therefore degrades coherently, while one that queries the viewport
  can end up in a layout its actual box cannot hold.
* **Nothing may be reachable in only one mode.** Content that appears at
  `expanded` and has no route at `compact` is content a reader at 400% zoom
  cannot get to. Where a wider mode surfaces something inline, the compact mode
  reaches the same thing through a sheet or a disclosure, and never leaves it
  unreachable.
* **Reading order is identical across modes.** The DOM order does not change with
  the container; only the visual arrangement does. A layout that reorders content
  visually without reordering it in the DOM gives keyboard and screen-reader
  users a different sequence from everyone else.
* **The floors hold in every mode.** Target size, contrast and the text-size
  multiplier are not relaxed because a screen is large. A wide screen may still
  be a wall-mounted display read from two metres away.

## Related [#related]

* [Layout](../layout.mdx) covers the shell whose regions establish these
  containers, and the two canonical screen shapes.
* [Dynamic Type](../typography/dynamic-type.mdx) is the other axis a component
  has to survive, and the one that interacts with this most.
* [Density and touch](./density-and-touch.mdx) has the floors that do not move
  between modes.
