opsinjs
FoundationsToken familiesTypography

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.

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 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 contrast and 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.
  • 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.

On this page