opsinjs
FoundationsToken familiesSpace and density

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:

ModeShapeWhat appears
compactA single column; the shell is the screenOne thing at a time; detail arrives as a sheet
mediumA single wider column, or two narrow onesRelated items pair up; the sheet gets a detent
expandedTwo regions side by sideList and detail together; the sheet becomes a pane
wideTwo regions plus contextHistory, 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.

TokenWhat it controlsUsed by
--opsin-space-00rem0px.no component
--opsin-space-10.25remGap 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.5remGap 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.75remInner 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-41remThe 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.25remCard inner padding on a phone.avatar, button, consent-sheet, dialog, questionnaire, sheet, slider, switch, symptom-picker
--opsin-space-61.5remCard 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-82remGap between sections within a screen.avatar, body-map, disclaimer-note, popover, range-bar, reading-input, sheet, stepper
--opsin-space-102.5remSpace above a section heading.avatar, sheet, skeleton, switch
--opsin-space-123remGap between major regions of a screen.avatar, trend-sparkline
--opsin-space-164remTop of a screen below the safe area; the space a consent sheet leaves above its first question.dialog, skeleton
--opsin-space-205remEmpty-state vertical rhythm.no component
--opsin-space-246remThe largest step. Beyond this, use a layout, not a gap.no component
--opsin-space-px0.0625remHairline borders only.body-map, trend-sparkline
--opsin-space-0-50.125remOptical 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.75remThe 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-comfortable3remThe default for a primary action in the product theme.dialog
--opsin-target-generous3.5remA single primary action on a consent, escalation or emergency surface, where a mis-tap has a real cost.no component
--opsin-target-separation0.5remThe 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-phone16pxThe screen gutter in the phone responsive mode.no component
--opsin-gutter-tablet24pxThe screen gutter in the tablet responsive mode.no component
--opsin-gutter-wide32pxThe screen gutter in the wide responsive mode.no component
--opsin-measure-tight45chA caption or a legend.dialog, empty-state, goal-ring, popover, tooltip
--opsin-measure-comfortable66chThe 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-wide80chCode and machine output only.no component
--opsin-graphic-dial17remThe 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. 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.
  • 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.

On this page