opsinjs
FoundationsToken familiesTypography

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.

Overview

Almost every design system sizes type with a t-shirt scale: xs, sm, base, lg, xl, and upwards. It is easy to learn and it has one fatal property for a health product. The scale names the size, so a component that wants "the size a footnote should be" has to hard-code the answer.

The moment the reader changes their text size, that hard-coded answer is the one thing that cannot follow, because it was written as a number in a component rather than resolved from a role. A scale addressed by size also hides the decision a component is making. It asked for text-xs when what it meant was a footnote, and text-xs is a number, so nobody can retune what a footnote should be without hunting down every place that number was pasted.

opsinjs therefore uses a semantic scale, in the manner of Apple's Dynamic Type text styles: roles named for the job they do. A component asks for footnote and the system decides what a footnote is at the current text size, on this device, in this container. The mapping can change; the intent cannot.

How it works

There is one family of roles, named for the job each does rather than for its size, and the table below is the whole of it. There is no separate family for numerals; a measurement is a reading role too, and how it gets tabular figures is the second half of this section.

Reading roles carry words. Every role name below is a real token: prefix it with text-opsin- and it is the utility class that sets the role.

RoleJobNotes
large-titleThe largest step, for a surface whose whole subject is one number or one wordAt most one per screen; no component sets a value here yet
title1A screen title, and the hero size a value's number takesValue renders its display number in this role
title2Section title, and the floor for a primary health value inside a cardThe smallest a primary reading may use
title3A Dialog or Sheet title, and the unit beside a display valueModal titles live here
headlineA card's title, and the emphasised lead line inside oneSame size as body, heavier
bodyDefault reading size, 17pxEverything not otherwise specified; the anchor of the scale
calloutSecondary prose inside a dense containerSlightly smaller, same leading ratio
subheadlineThe label above or beside a valueAn emphasised twin, subheadline-emphasis, is the same size at a heavier weight for a compact control's label
footnoteProvenance: who measured this, when, with whatThe freshness line lives here
caption1Axis labels, legends, legal textAbove the floor by one step
caption2The smallest text the system permitsThe floor; only for text that repeats a label present elsewhere

A card's title sits at headline, not title3, and the table says so. The reason is what happens at 200% text: a title3 title reaches 40px, which splits a word across two lines inside a 336px card, while headline at the same multiplier wraps at a space. A modal title is a screen title rather than a card title, so title3 is where a Dialog or a Sheet heading belongs and the only place it does. On a system that is often read one-handed by people over 60, a card should not spend a size step it does not need.

A measurement is a reading role, not a separate family. There is no value role family and no text-opsin-value-* utility to reach for. A health value is set in a reading role at title2 or larger, and never below body, which is the second rule in tokens/type.json. Its label sits in subheadline, its unit a step down from the number, and its provenance in footnote. So the same eleven roles carry both the words and the numbers.

Tabular figures come from a mechanism, not from the role. Anything carrying the data-opsinjs-value attribute is set in font-variant-numeric: tabular-nums by the product theme, and the numeric family is reached through font-opsin-numeric. A number therefore gets stable, aligned digits because of what it is, an attribute the component anatomy sets, rather than because of which size role it happened to land in. That is the mechanism the system actually ships, and it is why a measurement in title1 and a measurement in title2 both align.

Tabular figures are not a stylistic choice here. Proportional digits have different widths, so 111 and 100 are different lengths; a column of readings does not align, a value that updates shifts the layout under the reader's thumb, and a chart axis wobbles as it redraws. Putting the numeric variant on the value attribute rather than asking each component to remember it is what makes it impossible to forget.

A role is four values, not one. Each carries a size, a line height, a tracking adjustment and a weight, because they are not independent: large type needs tighter tracking and proportionally tighter leading, small type needs the reverse. Shipping only sizes is how a system ends up with headings that are correctly sized and set too loose.

A role is not a heading level. title2 is an appearance; h2 is document structure. Choose the heading level from the outline of the page and the role from what it should look like. Choosing an h4 because you wanted smaller text is how a document becomes unnavigable for anybody who moves through it by heading.

Your blood pressure was 148 over 92

At 200%

Your blood pressure was 148 over 92

Using it

Default to body and justify anything else. Most text in a health product is body text. A screen with six distinct type roles is usually a screen that has not decided what it is about.

Give a measurement a size of its own, and keep its label and provenance smaller.

Do

Resting heart rate in subheadline, 62 in title1 carrying data-opsinjs-value, bpm in title3, Measured 3 hours ago by your watch in footnote. Four reading roles, four different jobs, and each one is doing exactly its own.

Don’t

All four in body with the number bolded. Nothing is subordinate to anything; because nothing carries the value attribute the digits stay proportional and the tile jumps every time the reading changes; and the provenance line has the same weight as the measurement it qualifies.

Do not add a step. If a size is missing, the usual cause is that a role is being used for the wrong job. A genuine gap is a token proposal rather than a local override, because a local size does not participate in Dynamic Type. See Contributing tokens.

Two weights, three at the outside. Regular for reading, a medium or semibold for emphasis, and a heavier weight reserved for a measurement set apart from running text. Systems with five weights use them to imply importance, and implied importance on a health screen competes with clinical status. See rule 3 on Typography.

Do not set the same content in two roles at two breakpoints. The role is already responsive; overriding it at a breakpoint takes it out of the scale and out of Dynamic Type at the same time.

Tokens

Every role's size, line height, tracking and weight is generated from tokens/type.json by scripts/build-tokens.mts. Every role scales by the same factor when the reader changes their text size, because the multiplier is applied once at the root rather than per role. Specimens are rendered from the generated values, so what you see is the token rather than an illustration of it.

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

  • A semantic scale keeps the reader's own setting intact. Because every role resolves to rem and a component asks for the role rather than a pixel size, the reader's text-size multiplier and their browser zoom both reach every step. A single hard-coded size anywhere opts that one element out, and the failure is invisible until somebody actually turns their text size up.
  • caption2 is the floor and it is a real floor. Nothing in the system is smaller, and caption2 is still measured for contrast at its own size and weight, because APCA is sensitive to both and a plain WCAG ratio is not.
  • Tabular figures materially help low-numeracy and dyslexic readers, who are a large proportion of any consumer health audience. Alignment is not decoration; it is what lets somebody compare two numbers without counting digits.
  • Roles and heading levels must not be conflated. Screen-reader users navigate by heading level. A page whose levels were chosen for their appearance is a page whose structure lies.
  • Nothing may be smaller than caption2 to make something fit. If text does not fit, the layout is wrong. Shrinking below the floor is how a system quietly ships unreadable provenance lines.
  • Dynamic Type says what these roles do as the reader's multiplier changes, and covers the 1.3× bar every component must clear.
  • Numbers, units and precision has the canonical rules for what a number should say; this page only sets it.
  • Typography tokens has the generated values behind every role.

On this page