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.
Overview
opsinjs does not ship a typeface. The product theme's --font-sans starts with
ui-sans-serif, system-ui and lets the device answer, which on an iPhone means SF
Pro, on Android means Roboto, and on Windows means Segoe UI Variable.
This is a bet, and it is worth stating what is being bet. The thing given up is brand consistency: the product does not look identical on every device. Three things are bought in exchange, and for a patient-facing product each of them is worth more.
It is the font the reader already reads. Every other app on their phone uses it. It is optimised for the display in their hand, it has been hinted for it, and it is the shape their eye is calibrated to. A worried person skimming a reading does not need a novel letterform.
It respects their text size. The platform UI font is the one the operating system's own text-size controls are designed around, and it carries the optical sizing and metric variations those controls rely on. This matters more than any other single typographic decision in a health product.
It arrives instantly, and it never fails. No webfont request, no flash of invisible or unstyled text, no font-loading race on a slow connection in a hospital corridor. Type has no loading state.
On the licence question, because it comes up immediately: SF Pro cannot be
shipped as a webfont. Apple licenses it for building interfaces on Apple
platforms, not for redistribution, and self-hosting it on the web is outside
those terms. Read Apple's font licence yourself rather than taking this page's
word for it. system-ui sidesteps the problem entirely: on Apple devices it
resolves to the copy already installed, with no file served and no licence
question to answer.
A brand face remains available as an opt-in. This documentation site itself uses Inter, because the docs chrome is a different product with a different reader. The reasoning is on Lyra and the docs chrome.
How it works
Three pages sit under this one: the type scale, which is semantic rather than a t-shirt sizing; Dynamic Type, which is the reader's own text-size multiplier and the thing every component must survive; and the generated tokens.
Four rules run across all three.
1. The body size is 17px, not 16px. One pixel sounds like nothing and is not. 17 is the platform default on iOS, it is what the reader's eye is set to from every other app, and the extra pixel is the cheapest legibility improvement available to a product whose readers skew older than a developer tools audience. The docs chrome is denser on purpose; the product is not a developer tool.
2. Every metric is set in tabular figures. Any number that will be read
against another number uses font-variant-numeric: tabular-nums. That covers a
reading, a range bound, an axis label, a time, a count. Proportional digits
change width as the value changes, so a heart rate ticking from 99 to 100 shifts
the layout, and a column of readings does not line up so the eye cannot scan it.
This is not a preference. It is a correctness requirement on a screen of numbers,
and the system carries it on the data-opsinjs-value attribute rather than
leaving each component to remember it.
3. Type never carries status on its own. Bold is not urgent, red is not severe, and a larger size is not more important clinically. Weight and size express hierarchy; clinical status is expressed by colour, icon and word together. A system in which the urgent reading is simply bolder has no way to escalate.
4. Line length is capped, everywhere. A measure of roughly 60 to 75 characters is the readable range for continuous prose, and a health explanation on a tablet held in landscape will blow straight past it if nothing stops it. Wide screens get more content, not longer lines. The mechanism is on Responsive modes.
Using it
Pick the role, never the size. The scale is one family of eleven reading
roles named for the jobs they do: body, headline, footnote, a title for a screen,
a caption for a label. There is no separate family for values. Choosing title2
because it is "about the right size" is how a system acquires four different
heading sizes that mean nothing.
A measurement is a reading role too. A health value is set in a reading role
at title2 or larger, never below body, with its label in subheadline and
its provenance in footnote. There is no text-opsin-value-* utility to reach
for. The tabular, aligned figures a number needs come from the
data-opsinjs-value attribute and font-opsin-numeric, not from the role, so a
value stays stable as it updates because of what it is rather than because of
which size step it landed in.
Do
120/80 in title1 carrying data-opsinjs-value, with mmHg in title3
beside it at a smaller size and a lighter weight. The value attribute makes
the figures tabular, so the layout is stable as the reading changes, and the
unit is present but subordinate.
Don’t
The same reading and unit both in title1, and neither carrying the value
attribute. The digits reflow as the value changes because nothing set them
tabular, the unit competes with the number for attention at the same size,
and at a large text size the whole line wraps in the middle of the
measurement.
Never disable the reader's text scaling. Not with a viewport meta tag, not
with a fixed pixel size on the root, not with text-size-adjust: none. It is the
single most consequential accessibility setting on the device and overriding it
is a decision made against the person using the product.
Set language on the document. Correct hyphenation, correct default font selection, and correct screen-reader pronunciation all depend on it, and none of them announce themselves as broken.
Tokens
Sizes, line heights, tracking, weights and the numeric variants are generated
from tokens/type.json by scripts/build-tokens.mts. The full table with a
rendered specimen for each role is on Typography tokens.
| 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 200% text size must not break any layout in the system, and a 1.3× multiplier must not break anything at all: no clipping, no truncation, no overlap, no horizontal scroll. The check and the components most at risk are on Dynamic Type and Text resizing and zoom.
- Text is text. No value, unit, label or status word is ever rendered as an image or as an icon font. It must be selectable, translatable, findable and readable by a screen reader.
- Contrast is measured per role, not assumed. APCA is sensitive to size and
weight, so a pair that passes for a heading can fail for a footnote. Every
pairing of a role with a surface is measured by
pnpm contrastand published on Contrast conformance. - Tabular figures help more than layout. A column of aligned digits is substantially easier to scan for readers with dyslexia and for readers with low numeracy, which on a health screen is most readers.
- The platform font inherits accessibility work nobody else can do. Bold text, larger accessibility sizes and increased-contrast font weights are system-level features that only apply to the system font.
Related
- Type scale covers the roles, why they are semantic, and which one to reach for.
- Dynamic Type covers the reader's multiplier, and the components that have to survive it.
- Content and language covers the words inside the type; typography cannot rescue a badly written sentence.
Motion tokens
The generated reference for every duration, easing and spring in the system, with each curve plotted from the parameters it was derived from.
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.