Layout
The page shell, the safe-area and keyboard contract, and the two screen shapes that between them cover almost every consumer health surface.
Overview
Layout in opsinjs is deliberately small. There is no grid system, no twelve columns, no layout component zoo. A consumer health app is, structurally, a scrolling column with a persistent action, on a device held in one hand. Almost everything that goes wrong with its layout goes wrong at the edges: under the notch, behind the home indicator, beneath the on-screen keyboard, or above the fold on a small phone where the one thing the reader came for is the one thing they cannot see.
So this page specifies the shell, the edges, and two screen shapes. Everything about how the shell changes with available width belongs to Responsive modes, and everything about how much room things get belongs to Space.
The thing this is most often confused with is a page template. A template prescribes content. A shell prescribes the frame, the insets and the ordering rules. What content goes in it is a pattern or a screen.
How it works
The shell has four regions, and their order in the DOM is the order a screen reader meets them, which is not always the order they appear on screen:
- Header has the screen's name and at most one secondary action. It is a heading, not a picture of one.
- Content is a single scrolling column. Multiple scroll containers on one screen is the most reliable way to lose somebody using a keyboard or a switch.
- Action is the persistent primary action, if the screen has one. It is the last thing in the DOM and it is pinned visually, so tab order reaches it after the content rather than before it.
- Chrome is the tab bar or equivalent, outside the screen's landmark entirely.
The edges are a contract, not a per-screen decision. Every one of the four
regions resolves its outer padding from a token that already includes the
platform inset, so a component never writes env(safe-area-inset-bottom) itself
and a screen never ends up with a dismiss control under the home indicator. The
same applies to the on-screen keyboard: the action region and any focused field
sit above the keyboard inset, so typing a reading never hides the field you are
typing it into.
Reach beats symmetry. On a phone, the bottom third of the screen is where a thumb comfortably lands and the top corners are where it does not. Primary actions go low; destructive and irreversible ones do not go where a thumb rests by default. This is opinion informed by the platform guidance rather than a measured result, and it is stated as such.
Two shapes cover most screens.
The overview shell. One heading, then a vertical run of tiles or cards, one per category or per metric, then a persistent action. Its rules: every tile carries its own status and its own freshness; there is no sort order that mixes the two axes; and the screen has a stated escalation budget, because an overview is where alarm fatigue is manufactured. See Alarm fatigue.
The reading shell. One value at the top with its unit, its status and its range; then what it means; then what to do; then history. Its rules: the value is above the fold on the smallest supported screen, nothing may cover it (see Layering), and the meaning precedes the action because a reader who is told what to do before they are told what it is will usually do neither.
Using it
Do not invent a third shell without writing down why. Most requests for one are actually a request for a different arrangement of content inside the reading shell. If you genuinely need one, it belongs in Screens with a spec, not as a local layout.
Order for the reader, not for the designer.
Do
Reading shell: value → status → range → plain-English meaning → what to do → when it was taken → history. Somebody who stops reading after two lines has still got the two lines that matter most.
Don’t
Chart first, value below it. The chart is the most visually interesting part of the screen and the least useful thing to a person who wants to know if today's number is a problem. Put the number first and the chart under it.
One scroll container, one landmark set, one H1. The header region is the H1; sections inside content are H2. A screen with two H1s is a screen that will be described wrongly by every tool that summarises it, including the ones your readers use.
Content has a maximum measure, even on a large screen. Wide screens get more of the story visible at once, not wider paragraphs. The rule and its mechanism are on Responsive modes.
Tokens
Layout consumes the space scale, the safe-area insets and the container
thresholds; it owns no scale of its own. The shell's outer padding, the action
region's inset and the keyboard-avoidance offset 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
Layout is where document structure and visual arrangement can silently disagree, and every item below is a real failure that passes a visual review.
- DOM order is reading order. The pinned action region is last in the DOM even though it is visually low on screen, so tab order and screen-reader order both reach it after the content it applies to. Any layout technique that reorders content visually without reordering it in the DOM breaks this.
- Landmarks are not decoration. Header, main, and the chrome region are distinct landmarks with distinct names, which is how somebody using a screen reader skips to the content without listening to the tab bar first.
- Safe areas are an accessibility requirement. A control under the home indicator or behind the keyboard is not merely inconvenient; for somebody with limited dexterity it is unreachable, and for somebody using a screen reader it is reachable but described in the wrong place.
- Reflow. The shell must survive 400% zoom without a second scroll axis, and must survive a 1.3× text-size multiplier with no clipping at all. The checks are on Text resizing and zoom and Dynamic Type.
- Sticky regions must not eat the viewport. A sticky header plus a sticky action bar on a small phone at a large text size can leave almost no content visible. Both collapse under the compact mode rules rather than persisting at full height.
Related
- Responsive modes shows how this shell behaves as the container grows, and why the rule is "more of the story", not "bigger".
- Layering and elevation says what may sit over the shell, and the rule that nothing may cover the value.
- Screens covers whole-screen specimens built from these two shapes.
Data states
Empty, loading, error, stale and partial are the five states every data-bearing surface must render, so "we do not know" is never drawn as an ordinary reading.
Colour
How opsinjs decides what colour anything is. Two independent axes, one derivation engine, and a contrast floor the build refuses to ship below.