Dynamic Type
The reader's own text-size multiplier, the 1.3× bar every component must clear without breaking, and the layout habits that decide whether it does.
Overview
Dynamic Type is the name for the reader deciding how big text should be. Every platform has it, most people who need it have already set it, and almost every web interface ignores it.
For a consumer health product this is not a minor accommodation. The people most likely to be checking a blood-pressure reading are disproportionately likely to have set a larger text size, and they set it once, years ago, and then expect every app to work. An interface that clips, truncates or overlaps at their setting is not "slightly off" for them. It is the version of the product they have, permanently.
The requirement in opsinjs is stated as a bar rather than an aspiration: every component must survive a 1.3× multiplier with no clipping, no truncation, no overlap and no horizontal scroll, and the whole system must remain usable at 200%. 1.3× is chosen because it is unremarkable. It is well within the range people actually use, unlike 200%, which tends to be treated as a stunt and therefore excused.
How it works
Scaling is a real font-size change, not a transform. The docs previews and
the (view) routes implement the reader's multiplier by changing the root font
size, exactly as the platform does. That means text reflows, containers grow,
line counts change and layouts have to cope. A transform: scale() would look
similar in a screenshot and would prove nothing, because it cannot produce the
failure being tested for.
Every size in the system is relative. Type roles resolve to rem, spacing
resolves through Tailwind's --spacing, and radii resolve to rem. A single
px font size anywhere in a component is a component that has opted out.
Every role scales by the same factor. The multiplier is applied once, to the
root font size, so a caption and a title grow in the same proportion and the
relationships inside the scale hold at every size. This is the right trade for a
health product. A reader who has already set a larger system text size, and most
of the people checking a reading have, keeps that exact setting through, because
each role resolves to rem; the product does not second-guess it by tuning
roles against each other. The argument is on Type scale.
Touch targets do not shrink and do not stretch. The 44pt floor from Density and touch is a floor at every text size. A control's hit area grows with its label, never the reverse.
Text scaling and browser zoom are different mechanisms and both must work. Zoom scales everything including layout; text scaling changes only text, which is the harder case because it changes the ratio between text and everything around it. A layout that survives 400% zoom can still fail at 1.3× text.
result-card · base base · style base-lyraOpen under the product themeUsing it
The failures are predictable and there are five of them. Every one is caused by a layout habit that is fine at 1× and wrong above it.
Fixed heights. A card with a set height clips its own content the moment the text inside it needs another line. Use minimum heights and let content decide.
Single-line assumptions. white-space: nowrap on a label, a truncation with
an ellipsis, a value and a unit expected to sit on one line. At 1.3× the label
wraps, and if it cannot wrap it either overflows or disappears.
Horizontal pairs that must stay horizontal. A row of label and value works at 1×, needs to become a stack at 1.3×, and cannot decide that from the viewport width because the container may be a narrow tile on a wide screen. This is container-query territory. See Responsive modes.
Icons sized in pixels beside text. An icon that does not grow with its label
looks progressively more wrong and eventually stops reading as part of the same
element. Icons in text contexts size in em. The rule is on
Sizing and alignment.
Sticky chrome that eats the viewport. A sticky header and a pinned action bar at 1.3× on a small phone can leave a strip of content between them. Both must collapse rather than persist at full height.
Do
A metric tile with a minimum height, a label allowed to wrap to two lines, and a container query that stacks the value beneath the label when the tile is narrow relative to its content. It grows; nothing is lost.
Don’t
The same tile with a fixed height and a truncated label. At 1.3× the reader
sees Resting heart ra… above a number with no unit, which is not a degraded
experience. It is a health value with its meaning removed.
Test it as a habit, not as an audit. Every <ComponentPreview> on this site
carries a text-size switch for exactly this reason: checking 1.3× should cost one
click while you are building, not a scheduled review three weeks before release.
Never truncate a value, a unit or a status word. If something has to be cut, it is not one of those three. A truncated unit turns a measurement into a different measurement.
Tokens
The multiplier steps used by the preview switch, and the size, line height,
tracking and weight of every role, are generated from tokens/type.json by
scripts/build-tokens.mts. The steps multiply the rem value of every role by the
same factor; they do not change the leading ratios, which are already
multipliers.
| Token | What it controls | Used by |
|---|---|---|
| --opsin-font-sanssystem-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif | The sans family stack. | no component |
| --opsin-font-monoui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace | The mono family stack. | no component |
| --opsin-font-numericsystem-ui, -apple-system, 'Segoe UI', Roboto, sans-serif | The numeric family stack. | result-card, value |
| --opsin-text-large-title-size2.125rem | The largest step in the scale, reserved for a surface whose entire subject is one number or one word. No component in the system sets a value at this step yet. | no component |
| --opsin-text-large-title-leading1.206 | Line height for largeTitle, unitless so it scales with the size. | no component |
| --opsin-text-large-title-tracking-0.011em | Letter spacing for largeTitle. | no component |
| --opsin-text-large-title-weight700 | Font weight for largeTitle. | no component |
| --opsin-text-title1-size1.75rem | A screen title, and the hero treatment a value's number takes at size display. | result-card, value |
| --opsin-text-title1-leading1.214 | Line height for title1, unitless so it scales with the size. | result-card, value |
| --opsin-text-title1-tracking-0.009em | Letter spacing for title1. | result-card, value |
| --opsin-text-title1-weight700 | Font weight for title1. | result-card, value |
| --opsin-text-title2-size1.375rem | Section title; the floor for a primary health value inside a card. | goal-ring |
| --opsin-text-title2-leading1.273 | Line height for title2, unitless so it scales with the size. | goal-ring |
| --opsin-text-title2-tracking-0.006em | Letter spacing for title2. | goal-ring |
| --opsin-text-title2-weight600 | Font weight for title2. | goal-ring |
| --opsin-text-title3-size1.25rem | A Dialog or Sheet title, and the unit beside a value's number at size display. | dialog, questionnaire, reading-input, sheet, value |
| --opsin-text-title3-leading1.25 | Line height for title3, unitless so it scales with the size. | dialog, questionnaire, reading-input, sheet, value |
| --opsin-text-title3-tracking-0.004em | Letter spacing for title3. | dialog, questionnaire, reading-input, sheet, value |
| --opsin-text-title3-weight600 | Font weight for title3. | dialog, questionnaire, reading-input, sheet, value |
| --opsin-text-headline-size1.0625rem | An emphasised line of body text, such as a status sentence or a question in a form. | accordion, alert-banner, avatar, button, callout, card, care-card, empty-state, field, log-sheet, menu, popover, range-bar, reading-input, result-card, scale-input, segmented-control, status-pill, surface, tabs, textarea, timeline-entry, toast, trend-sparkline, value |
| --opsin-text-headline-leading1.294 | Line height for headline, unitless so it scales with the size. | accordion, alert-banner, avatar, button, callout, card, care-card, empty-state, field, log-sheet, menu, popover, range-bar, reading-input, result-card, scale-input, segmented-control, status-pill, surface, tabs, textarea, timeline-entry, toast, trend-sparkline, value |
| --opsin-text-headline-tracking-0.003em | Letter spacing for headline. | accordion, alert-banner, avatar, button, callout, card, care-card, empty-state, field, log-sheet, menu, popover, range-bar, reading-input, result-card, scale-input, segmented-control, status-pill, surface, tabs, textarea, timeline-entry, toast, trend-sparkline, value |
| --opsin-text-headline-weight600 | Font weight for headline. | accordion, alert-banner, avatar, button, callout, card, care-card, empty-state, field, log-sheet, menu, popover, range-bar, reading-input, result-card, scale-input, segmented-control, status-pill, surface, tabs, textarea, timeline-entry, toast, trend-sparkline, value |
| --opsin-text-body-size1.0625rem | Everything a reader reads as prose. The anchor of the scale. | accordion, alert-banner, badge, callout, card, care-card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, empty-state, field, link, menu, number-field, popover, questionnaire, radio-group, range-bar, range-legend, reading-input, relative-time, result-card, score-dial, scroll-area, select, sheet, surface, switch, symptom-picker, table, tabs, term, textarea, timeline-entry, toast, tooltip, trend-sparkline, value, visually-hidden |
| --opsin-text-body-leading1.294 | Line height for body, unitless so it scales with the size. | accordion, alert-banner, badge, callout, card, care-card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, empty-state, field, link, menu, number-field, popover, questionnaire, radio-group, range-bar, range-legend, reading-input, relative-time, result-card, score-dial, scroll-area, select, sheet, skeleton, surface, switch, symptom-picker, table, tabs, term, textarea, timeline-entry, toast, tooltip, trend-sparkline, value, visually-hidden |
| --opsin-text-body-tracking-0.003em | Letter spacing for body. | accordion, alert-banner, badge, callout, card, care-card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, empty-state, field, link, menu, number-field, popover, questionnaire, radio-group, range-bar, range-legend, reading-input, relative-time, result-card, score-dial, scroll-area, select, sheet, surface, switch, symptom-picker, table, tabs, term, textarea, timeline-entry, toast, tooltip, trend-sparkline, value, visually-hidden |
| --opsin-text-body-weight400 | Font weight for body. | accordion, alert-banner, badge, callout, card, care-card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, empty-state, field, link, menu, number-field, popover, questionnaire, radio-group, range-bar, range-legend, reading-input, relative-time, result-card, score-dial, scroll-area, select, sheet, surface, switch, symptom-picker, table, tabs, term, textarea, timeline-entry, toast, tooltip, trend-sparkline, value, visually-hidden |
| --opsin-text-callout-size1rem | Secondary prose inside a card or a callout. | dialog, sheet, toast |
| --opsin-text-callout-leading1.313 | Line height for callout, unitless so it scales with the size. | dialog, sheet, toast |
| --opsin-text-callout-tracking-0.002em | Letter spacing for callout. | dialog, sheet, toast |
| --opsin-text-callout-weight400 | Font weight for callout. | dialog, sheet, toast |
| --opsin-text-subheadline-size0.9375rem | A supporting line under a title. | button, care-card, empty-state, goal-ring, metric-tile, progress, radio-group, score-dial, segmented-control, slider, spinner, status-pill, stepper, table |
| --opsin-text-subheadline-leading1.333 | Line height for subheadline, unitless so it scales with the size. | button, care-card, empty-state, goal-ring, metric-tile, progress, radio-group, score-dial, segmented-control, slider, spinner, status-pill, stepper, table |
| --opsin-text-subheadline-tracking-0.001em | Letter spacing for subheadline. | button, care-card, empty-state, goal-ring, metric-tile, progress, radio-group, score-dial, segmented-control, slider, spinner, status-pill, stepper, table |
| --opsin-text-subheadline-weight400 | Font weight for subheadline. | button, care-card, empty-state, goal-ring, metric-tile, progress, radio-group, score-dial, segmented-control, slider, spinner, status-pill, stepper, table |
| --opsin-text-subheadline-emphasis-size0.9375rem | The emphasised twin of subheadline, for a compact control's label, where the control must read as narrower and never as quieter. | avatar, button |
| --opsin-text-subheadline-emphasis-leading1.333 | Line height for subheadlineEmphasis, unitless so it scales with the size. | avatar, button |
| --opsin-text-subheadline-emphasis-tracking-0.001em | Letter spacing for subheadlineEmphasis. | avatar, button |
| --opsin-text-subheadline-emphasis-weight600 | Font weight for subheadlineEmphasis. | avatar, button |
| --opsin-text-footnote-size0.8125rem | Provenance: who measured this, when, with what. | body-map, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, goal-ring, log-sheet, menu, metric-tile, questionnaire, radio-group, range-bar, range-legend, result-card, scale-input, score-dial, sheet, source-citation, status-pill, stepper, switch, symptom-picker, table, timeline-entry, toast, tooltip, trend-sparkline |
| --opsin-text-footnote-leading1.385 | Line height for footnote, unitless so it scales with the size. | body-map, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, goal-ring, log-sheet, menu, metric-tile, questionnaire, radio-group, range-bar, range-legend, result-card, scale-input, score-dial, sheet, source-citation, status-pill, stepper, switch, symptom-picker, table, timeline-entry, toast, tooltip, trend-sparkline |
| --opsin-text-footnote-tracking0em | Letter spacing for footnote. | body-map, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, goal-ring, log-sheet, menu, metric-tile, questionnaire, radio-group, range-bar, range-legend, result-card, scale-input, score-dial, sheet, source-citation, status-pill, stepper, switch, symptom-picker, table, timeline-entry, toast, tooltip, trend-sparkline |
| --opsin-text-footnote-weight400 | Font weight for footnote. | body-map, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, dose-tracker, goal-ring, log-sheet, menu, metric-tile, questionnaire, radio-group, range-bar, range-legend, result-card, scale-input, score-dial, sheet, source-citation, status-pill, stepper, switch, symptom-picker, table, timeline-entry, toast, tooltip, trend-sparkline |
| --opsin-text-caption1-size0.75rem | Axis labels, legend text, and legal text. | avatar, badge, consent-sheet, dose-tracker, range-bar, skeleton, tab-bar, visually-hidden |
| --opsin-text-caption1-leading1.333 | Line height for caption1, unitless so it scales with the size. | avatar, badge, consent-sheet, dose-tracker, range-bar, skeleton, tab-bar, visually-hidden |
| --opsin-text-caption1-tracking0.002em | Letter spacing for caption1. | avatar, badge, consent-sheet, dose-tracker, range-bar, skeleton, tab-bar, visually-hidden |
| --opsin-text-caption1-weight400 | Font weight for caption1. | avatar, badge, consent-sheet, dose-tracker, range-bar, skeleton, tab-bar, visually-hidden |
| --opsin-text-caption2-size0.6875rem | The smallest text the system permits, and only for text that repeats a label already present elsewhere. Never the only place a fact appears. | no component |
| --opsin-text-caption2-leading1.182 | Line height for caption2, unitless so it scales with the size. | no component |
| --opsin-text-caption2-tracking0.005em | Letter spacing for caption2. | no component |
| --opsin-text-caption2-weight500 | Font weight for caption2. | no component |
| --opsin-numeralstabular-nums | Every component that renders a number sets this, so a changing value does not shift its own layout. | no component |
Accessibility impact
- WCAG 2.2 SC 1.4.4 Resize Text requires 200% without loss of content or functionality, and SC 1.4.10 Reflow requires no two-dimensional scrolling at 320 CSS pixels of width. The 1.3× bar is stricter in practice than either, because it is applied to every component rather than to a page as a whole.
- 1.3× is the working bar and 200% is the conformance bar. Components are built and reviewed at 1.3×; the system is verified at 200%. Anything that fails at 1.3× would fail catastrophically at 200%, so the earlier check catches more.
- Truncation is content loss. An ellipsis at a larger text size fails SC 1.4.4 as surely as a clipped container does, and it is the failure most likely to survive review because it looks deliberate.
- This interacts with every other foundation. Space decides whether there is room, shape decides whether the corners still look right at a larger radius, iconography decides whether the glyph kept up, and layout decides whether the shell still fits. Dynamic Type is where the whole system is tested at once.
- The docs site is held to the same bar, and the result is published on Text resizing and zoom and This site's accessibility.
Related
- Type scale explains why a scale named for jobs lets every role scale together and keeps the reader's own setting.
- Responsive modes covers container queries, and why a component cannot decide its layout from the viewport.
- Text resizing and zoom has the conformance position, the test steps and the measured results.
Type scale
A scale named for jobs rather than sizes, one family of reading roles, and why a measurement gets tabular figures from an attribute, not a separate family.
Typography tokens
The generated reference for every type role, with a rendered specimen for each. Each entry lists the role's size, line height, tracking and weight.