Responsive modes
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.
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: components query their container, not the viewport.
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 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.
/* 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
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.
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.
Don’t
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.
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.
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.
Don’t
@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.
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
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.
| Token | What it controls | Used by |
|---|---|---|
| --opsin-space-00rem | 0px. | no component |
| --opsin-space-10.25rem | Gap between an icon and its label. | badge, button, callout, care-card, checkbox, combobox, dialog, dose-tracker, empty-state, field, goal-ring, log-sheet, menu, questionnaire, radio-group, range-bar, result-card, scale-input, scroll-area, segmented-control, select, sheet, slider, source-citation, status-pill, stepper, surface, symptom-picker, tab-bar, tabs, timeline-entry, toast, tooltip, trend-sparkline |
| --opsin-space-20.5rem | Gap between tightly related lines. For the separation between two interactive targets see targets.separation, which is the same number and carries its scope. | alert-banner, avatar, badge, body-map, button, callout, combobox, consent-sheet, dialog, disclaimer-note, dose-tracker, empty-state, field, icon-button, log-sheet, menu, metric-tile, number-field, popover, progress, questionnaire, radio-group, range-bar, range-legend, reading-input, relative-time, scale-input, score-dial, scroll-area, segmented-control, select, sheet, skeleton, slider, spinner, status-pill, surface, symptom-picker, tab-bar, table, tabs, textarea, timeline-entry, toast, tooltip, trend-sparkline, visually-hidden |
| --opsin-space-30.75rem | Inner padding of a compact control. | accordion, alert-banner, button, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, empty-state, field, log-sheet, menu, metric-tile, popover, progress, radio-group, reading-input, result-card, scroll-area, segmented-control, select, sheet, slider, status-pill, stepper, surface, symptom-picker, tab-bar, table, tabs, textarea, timeline-entry, toast, tooltip, value |
| --opsin-space-41rem | The default gap between elements inside a card. | accordion, avatar, badge, button, callout, care-card, consent-sheet, dialog, divider, empty-state, goal-ring, link, log-sheet, menu, popover, range-legend, result-card, scale-input, score-dial, segmented-control, sheet, surface, switch, tabs, timeline-entry, toast |
| --opsin-space-51.25rem | Card inner padding on a phone. | avatar, button, consent-sheet, dialog, questionnaire, sheet, slider, switch, symptom-picker |
| --opsin-space-61.5rem | Card inner padding on a wide screen; gap between cards. | avatar, card, dialog, disclaimer-note, empty-state, field, log-sheet, skeleton, stepper, switch, tab-bar, trend-sparkline |
| --opsin-space-82rem | Gap between sections within a screen. | avatar, body-map, disclaimer-note, popover, range-bar, reading-input, sheet, stepper |
| --opsin-space-102.5rem | Space above a section heading. | avatar, sheet, skeleton, switch |
| --opsin-space-123rem | Gap between major regions of a screen. | avatar, trend-sparkline |
| --opsin-space-164rem | Top of a screen below the safe area; the space a consent sheet leaves above its first question. | dialog, skeleton |
| --opsin-space-205rem | Empty-state vertical rhythm. | no component |
| --opsin-space-246rem | The largest step. Beyond this, use a layout, not a gap. | no component |
| --opsin-space-px0.0625rem | Hairline borders only. | body-map, trend-sparkline |
| --opsin-space-0-50.125rem | Optical nudges. Not a layout step. | badge, dose-tracker, radio-group, range-legend, result-card, scroll-area, segmented-control, status-pill, stepper, switch, tab-bar, tabs, term, trend-sparkline |
| --opsin-target-minimum2.75rem | The opsinjs floor for any interactive control, applied to the hit area rather than to the visible box. | accordion, body-map, button, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, field, icon-button, menu, metric-tile, number-field, popover, questionnaire, radio-group, reading-input, scale-input, segmented-control, select, sheet, slider, source-citation, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip |
| --opsin-target-comfortable3rem | The default for a primary action in the product theme. | dialog |
| --opsin-target-generous3.5rem | A single primary action on a consent, escalation or emergency surface, where a mis-tap has a real cost. | no component |
| --opsin-target-separation0.5rem | The minimum gap between two adjacent targets whose visible boxes are smaller than 44px. It is a floor for undersized targets only. Two targets that both meet the 44px minimum are outside its scope, and separating those is governed by the differing cost of a mis-tap between them rather than by a fixed gap. | alert-banner, card, care-card, consent-sheet, result-card |
| --opsin-gutter-phone16px | The screen gutter in the phone responsive mode. | no component |
| --opsin-gutter-tablet24px | The screen gutter in the tablet responsive mode. | no component |
| --opsin-gutter-wide32px | The screen gutter in the wide responsive mode. | no component |
| --opsin-measure-tight45ch | A caption or a legend. | dialog, empty-state, goal-ring, popover, tooltip |
| --opsin-measure-comfortable66ch | The maximum line length for prose anywhere in the product, including a disclaimer nobody wants to read. | disclaimer-note, result-card, score-dial, sheet, source-citation, term |
| --opsin-measure-wide80ch | Code and machine output only. | no component |
| --opsin-graphic-dial17rem | The maximum width of the ScoreDial arc. This is 16em against the product body of 1.0625rem, which is 17rem and lands on the 4px grid at 68 steps, and it yields to w-full when the column is narrower. | goal-ring, score-dial |
| --opsin-safe-topenv(safe-area-inset-top, 0px) | The top safe-area inset, for installed web apps where browser chrome does not protect the edge. | no component |
| --opsin-safe-rightenv(safe-area-inset-right, 0px) | The right safe-area inset, for installed web apps where browser chrome does not protect the edge. | sheet |
| --opsin-safe-bottomenv(safe-area-inset-bottom, 0px) | The bottom safe-area inset, for installed web apps where browser chrome does not protect the edge. | dialog, sheet |
| --opsin-safe-leftenv(safe-area-inset-left, 0px) | The left safe-area inset, for installed web apps where browser chrome does not protect the edge. | sheet |
Accessibility impact
- WCAG 2.2 SC 1.4.10 Reflow requires content to work at 320 CSS pixels of
width without two-dimensional scrolling.
compactis 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
expandedand has no route atcompactis 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
- Layout covers the shell whose regions establish these containers, and the two canonical screen shapes.
- Dynamic Type is the other axis a component has to survive, and the one that interacts with this most.
- Density and touch has the floors that do not move between modes.
Density and touch
A 44pt floor measured on the interactive area rather than the visual box, the separation rule that stops adjacent mis-taps, and where a primary action belongs.
Space tokens
The generated reference for the root spacing value, the numeric steps derived from it, the named roles, the target floor and the four mode thresholds.