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.
| Role | Job | Notes |
|---|---|---|
large-title | The largest step, for a surface whose whole subject is one number or one word | At most one per screen; no component sets a value here yet |
title1 | A screen title, and the hero size a value's number takes | Value renders its display number in this role |
title2 | Section title, and the floor for a primary health value inside a card | The smallest a primary reading may use |
title3 | A Dialog or Sheet title, and the unit beside a display value | Modal titles live here |
headline | A card's title, and the emphasised lead line inside one | Same size as body, heavier |
body | Default reading size, 17px | Everything not otherwise specified; the anchor of the scale |
callout | Secondary prose inside a dense container | Slightly smaller, same leading ratio |
subheadline | The label above or beside a value | An emphasised twin, subheadline-emphasis, is the same size at a heavier weight for a compact control's label |
footnote | Provenance: who measured this, when, with what | The freshness line lives here |
caption1 | Axis labels, legends, legal text | Above the floor by one step |
caption2 | The smallest text the system permits | The 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.
| 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
- A semantic scale keeps the reader's own setting intact. Because every role
resolves to
remand 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. caption2is the floor and it is a real floor. Nothing in the system is smaller, andcaption2is 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
caption2to 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.
Related
- 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.
Typography
Why opsinjs ships no webfont, what the platform UI font buys a health product, and the four rules every piece of type in the system obeys.
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.