opsinjs
FoundationsToken familiesTypography

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.

Theme
Density
Text
Status
result-card · base base · style base-lyraOpen under the product theme

Using 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.

TokenWhat it controlsUsed by
--opsin-font-sanssystem-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serifThe sans family stack.no component
--opsin-font-monoui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospaceThe mono family stack.no component
--opsin-font-numericsystem-ui, -apple-system, 'Segoe UI', Roboto, sans-serifThe numeric family stack.result-card, value
--opsin-text-large-title-size2.125remThe 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.206Line height for largeTitle, unitless so it scales with the size.no component
--opsin-text-large-title-tracking-0.011emLetter spacing for largeTitle.no component
--opsin-text-large-title-weight700Font weight for largeTitle.no component
--opsin-text-title1-size1.75remA screen title, and the hero treatment a value's number takes at size display.result-card, value
--opsin-text-title1-leading1.214Line height for title1, unitless so it scales with the size.result-card, value
--opsin-text-title1-tracking-0.009emLetter spacing for title1.result-card, value
--opsin-text-title1-weight700Font weight for title1.result-card, value
--opsin-text-title2-size1.375remSection title; the floor for a primary health value inside a card.goal-ring
--opsin-text-title2-leading1.273Line height for title2, unitless so it scales with the size.goal-ring
--opsin-text-title2-tracking-0.006emLetter spacing for title2.goal-ring
--opsin-text-title2-weight600Font weight for title2.goal-ring
--opsin-text-title3-size1.25remA 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.25Line height for title3, unitless so it scales with the size.dialog, questionnaire, reading-input, sheet, value
--opsin-text-title3-tracking-0.004emLetter spacing for title3.dialog, questionnaire, reading-input, sheet, value
--opsin-text-title3-weight600Font weight for title3.dialog, questionnaire, reading-input, sheet, value
--opsin-text-headline-size1.0625remAn 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.294Line 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.003emLetter 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-weight600Font 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.0625remEverything 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.294Line 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.003emLetter 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-weight400Font 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-size1remSecondary prose inside a card or a callout.dialog, sheet, toast
--opsin-text-callout-leading1.313Line height for callout, unitless so it scales with the size.dialog, sheet, toast
--opsin-text-callout-tracking-0.002emLetter spacing for callout.dialog, sheet, toast
--opsin-text-callout-weight400Font weight for callout.dialog, sheet, toast
--opsin-text-subheadline-size0.9375remA 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.333Line 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.001emLetter 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-weight400Font 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.9375remThe 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.333Line height for subheadlineEmphasis, unitless so it scales with the size.avatar, button
--opsin-text-subheadline-emphasis-tracking-0.001emLetter spacing for subheadlineEmphasis.avatar, button
--opsin-text-subheadline-emphasis-weight600Font weight for subheadlineEmphasis.avatar, button
--opsin-text-footnote-size0.8125remProvenance: 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.385Line 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-tracking0emLetter 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-weight400Font 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.75remAxis labels, legend text, and legal text.avatar, badge, consent-sheet, dose-tracker, range-bar, skeleton, tab-bar, visually-hidden
--opsin-text-caption1-leading1.333Line 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.002emLetter spacing for caption1.avatar, badge, consent-sheet, dose-tracker, range-bar, skeleton, tab-bar, visually-hidden
--opsin-text-caption1-weight400Font weight for caption1.avatar, badge, consent-sheet, dose-tracker, range-bar, skeleton, tab-bar, visually-hidden
--opsin-text-caption2-size0.6875remThe 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.182Line height for caption2, unitless so it scales with the size.no component
--opsin-text-caption2-tracking0.005emLetter spacing for caption2.no component
--opsin-text-caption2-weight500Font weight for caption2.no component
--opsin-numeralstabular-numsEvery 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.
  • 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.

On this page