---
title: "Type scale"
description: "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."
url: "https://opsinjs.pensievelabs.org/foundations/typography/type-scale"
source: "https://opsinjs.pensievelabs.org/foundations/typography/type-scale.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["text styles", "type ramp", "semantic type scale", "tabular numerals"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#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 [#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.

<TypeScaleSpecimen role="body" />

## Using it [#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.**

<DoDont>
  <DoDont.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.
  </DoDont.Do>

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

**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](../../handbook/contributing/contributing-tokens.mdx).

**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](../../health/clinical-status-semantics.mdx). See rule 3 on
[Typography](./index.mdx).

**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 [#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.

<TokenTable scope="type" />

## Accessibility impact [#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.

## Related [#related]

* [Dynamic Type](./dynamic-type.mdx) 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](../../health/numbers-units-precision.mdx) has
  the canonical rules for what a number should say; this page only sets it.
* [Typography tokens](./tokens.mdx) has the generated values behind every role.
