Typography
Set font families through CSS variables and load the fonts in your app. The registry bundles no font files. Components use the family tokens below; sizes, line heights, and weights remain component styles.
import { useState } from "react"
import "@fontsource/inter"
import "@fontsource/inter/700.css"
import "@fontsource/jetbrains-mono"
import "@fontsource/jetbrains-mono/700.css"
import "@fontsource/atkinson-hyperlegible-next"
import "@fontsource/atkinson-hyperlegible-next/700.css"
import "@fontsource/atkinson-hyperlegible-mono"
import "@fontsource/atkinson-hyperlegible-mono/700.css"
import { MONO_NUMERIC_CLASS, NUMERIC_CLASS, formatPrice } from "@/lib/format"
const PRICES = [1111.11, 8888.88, 1010.01, 7070.77]
const FRACTIONS = [99.515625, 98.03125, 101.75, 100.109375]
const FRACTION = { kind: "fraction", denominator: 32, half: "+" } as const
const SPECIMEN = "0O 1lI 5S 8B 69 3-5 4-6"
const SIZES = [12, 13, 14]
// Vary only the numeric features; the sample values stay fixed.
function Column({ label, variant, mono }: { label: string; variant: string; mono?: boolean }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase">{label}</span>
{(mono ? FRACTIONS : PRICES).map((price) => (
<span key={price} className={`text-right ${mono ? MONO_NUMERIC_CLASS : NUMERIC_CLASS}`} style={{ fontVariantNumeric: variant }}>
{mono ? formatPrice(price, FRACTION) : formatPrice(price, { kind: "decimal", decimals: 2 })}
</span>
))}
</div>
)
}
function Specimen({ family, label }: { family: string; label: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase">{label}</span>
{SIZES.map((size) => (
<span key={size} className="lining-nums tabular-nums" style={{ fontFamily: family, fontSize: size }}>
{SPECIMEN} <span className="text-muted-foreground">{size}px</span>
</span>
))}
</div>
)
}
export default function TypographyDemo() {
const [tabular, setTabular] = useState(true)
const [hyperlegible, setHyperlegible] = useState(false)
const variant = tabular ? "lining-nums tabular-nums" : "normal"
return (
<>
<div data-demo-controls className="mb-4 flex flex-wrap gap-2 font-(family-name:--tradecn-font-sans) text-xs">
<button type="button" className="rounded-md border border-border px-2 py-1" aria-pressed={tabular} onClick={() => setTabular((on) => !on)}>{tabular ? "Tabular figures: on" : "Tabular figures: off"}</button>
<button type="button" className="rounded-md border border-border px-2 py-1" aria-pressed={hyperlegible} onClick={() => setHyperlegible((on) => !on)}>{hyperlegible ? "Hyperlegible: on" : "Hyperlegible: off"}</button>
</div>
<div data-accessibility={hyperlegible ? "hyperlegible" : undefined} className="w-2xl max-w-full space-y-4 font-(family-name:--tradecn-font-sans) text-sm [--tradecn-font-numeric:var(--tradecn-font-sans)]">
<div className="mx-auto grid w-fit max-w-full grid-cols-2 gap-8" data-numeric-columns>
<Column label="Decimals" variant={variant} />
<Column label="32nds" variant={variant} mono />
</div>
<p className="text-xs text-muted-foreground">Compare the decimal points with tabular figures on and off. The numeric family follows the sans; fraction quotes use mono, whose characters already have equal widths.</p>
<div className="flex flex-wrap justify-center gap-6" data-specimens>
<Specimen family="var(--tradecn-font-sans)" label="Sans" />
<Specimen family="var(--tradecn-font-mono)" label="Mono" />
<Specimen family="Georgia, serif" label="System serif" />
</div>
<p className="text-xs text-muted-foreground">Hyperlegible switches the sans, numeric, and mono families inside this specimen. The serif comparison uses Georgia when installed, otherwise the system's serif fallback. Compare the glyphs at each size; this is not a font ranking.</p>
</div>
</>
)
}
Bring your own font
Set your stacks after the installed token declarations so they apply in both modes:
:root, .dark {
--tradecn-font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
--tradecn-font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
--tradecn-font-numeric defaults to var(--tradecn-font-sans). Point it at var(--tradecn-font-mono) if you want grid numbers in the mono family. Themes use the same family defaults and leave your app's --font-sans alone.
To make Tailwind's font-sans and font-mono utilities follow these tokens, including accessibility mode:
@theme inline {
--font-sans: var(--tradecn-font-sans);
--font-mono: var(--tradecn-font-mono);
}
Load whichever families you choose. The default Inter and JetBrains Mono families are free to use; this Google Fonts CSS request loads both variable weight ranges:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=JetBrains+Mono:wght@100..800&display=swap">
For self-hosting, Fontsource offers static packages with one import per weight, such as @fontsource/inter/500.css and @fontsource/jetbrains-mono/500.css. The playground and tradecn.dev self-host these fonts; previews make no third-party font requests.
Fontsource's @fontsource-variable/inter registers the family as 'Inter Variable'. If that is what your app loads, use 'Inter', 'Inter Variable', ui-sans-serif, system-ui, sans-serif or set --tradecn-font-sans: var(--font-sans) when the app already defines its stack. Do not combine the latter with the Tailwind alias above: the two variables would refer to each other.
Defaults
- Inter for prose, labels, and grid numbers. Its tabular figures let digits share a width while letters stay proportional.
- JetBrains Mono for code, order IDs, timestamps, and fraction quotes such as
99-16+. It has a dotted zero and distinct1,l, andIshapes (JetBrains). IBM Plex Mono is another option. - Atkinson Hyperlegible Next and Atkinson Hyperlegible Mono for accessibility mode. The Braille Institute designed the family to distinguish similar characters for readers with low vision.
Tokens
An item installs the tokens its source uses, their dependencies, and the accessible pair when it uses a font token. Non-theme installs add missing values to :root and .dark. Theme installs replace existing values and supply the full set below.
The themes install the size, line-height, weight, and body-color tokens below, and --tradecn-numeric-variant, for your own stylesheet; an item adds one only when its source reads it, as agent-kit adds --tradecn-text-size-grid-min. Otherwise declare the ones you use yourself. Setting a size token does not resize a component. Every grid preset uses text-xs (12 px with the default 16 px root size). The registry validator rejects explicit source sizes below 12 px; this is a source check, not a browser minimum under consumer CSS.
| Token | Default | Purpose |
|---|---|---|
--tradecn-font-sans |
'Inter', ui-sans-serif, system-ui, sans-serif |
Prose, labels, chrome |
--tradecn-font-mono |
'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace |
Code, IDs, timestamps, fraction quotes |
--tradecn-font-numeric |
var(--tradecn-font-sans) |
Grid numbers |
--tradecn-font-accessible |
'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif |
Accessible sans stack |
--tradecn-font-accessible-mono |
'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace |
Accessible mono stack |
--tradecn-text-size-body |
14px |
Suggested body size; the largest size the tokens suggest |
--tradecn-text-size-grid |
13px |
Suggested grid size |
--tradecn-text-size-grid-min |
12px |
Registry source size floor |
--tradecn-line-height-body |
1.45 |
Unitless body line height |
--tradecn-line-height-grid |
1.35 |
Unitless grid line height |
--tradecn-font-weight-body |
450 |
Suggested body weight |
--tradecn-font-weight-grid |
500 |
Suggested grid weight, in both modes |
--tradecn-numeric-variant |
lining-nums tabular-nums |
Required numeric variant |
--tradecn-color-body-fg |
Set per theme | Body foreground |
--tradecn-color-body-bg |
Set per theme | Body background |
Accessibility mode
Load Atkinson Hyperlegible Next and Mono, then put data-accessibility="hyperlegible" on <html>. Installs write the font tokens into .dark as well as :root, so an element carrying the dark class inside a hyperlegible page resets the fonts to the defaults within it; switch modes on <html> rather than nesting a dark wrapper when accessibility mode matters:
<html data-accessibility="hyperlegible">
The remap sets --tradecn-font-sans to var(--tradecn-font-accessible) and --tradecn-font-mono to var(--tradecn-font-accessible-mono). It ships with every item that uses a font token, so no theme is required. It also reaches your own text wherever you use those families.
The fonts are free from the Braille Institute, Google Fonts, and Fontsource (@fontsource/atkinson-hyperlegible-next, @fontsource/atkinson-hyperlegible-mono). The mode changes font selection; it does not load the fonts.
You can scope the attribute to an ancestor of one screen. To make numeric text follow a scoped sans change, redeclare the numeric alias on that ancestor too:
[data-accessibility="hyperlegible"] {
--tradecn-font-numeric: var(--tradecn-font-sans);
}
An inherited numeric alias has already resolved against its ancestor's sans value. An explicitly chosen numeric family also stays unchanged unless you override it. CSS custom properties resolve before inheritance.
The installed remap is unlayered and includes :root[data-accessibility="hyperlegible"] so it overrides ordinary root token declarations. Keep accessible stacks independent of the sans and mono tokens: a reference back to the token being remapped creates a cycle.
Numeric rendering
Any component rendering numeric data MUST set font-variant-numeric: lining-nums tabular-nums on the numeric node. This is rule 14 of the item contract; the numeric variant is fixed even when you change families.
Tabular figures have equal digit widths, so equally formatted values such as 1,111.11 and 8,888.88 align. Lining figures have a uniform height and baseline, unlike old-style figures with ascenders and descenders. CSS requests the font's numeric features; it cannot supply missing glyph designs (CSS Fonts). A font may already have the required figures by default without a separate tnum or lnum substitution.
Use the format helpers in your own cells:
| Export | Family and behavior |
|---|---|
NUMERIC_CLASS |
Numeric family with lining, tabular figures |
MONO_NUMERIC_CLASS |
Mono family with lining, tabular figures |
numericFontClass(convention) |
Mono for a fraction price, such as 99-16+; numeric for other prices and non-price quote bases |
Monospacing gives digits, separators, and suffixes equal widths. Quotes such as 99-16+ and 99-17 still need padding or separate alignment if their optional suffixes must line up.
UI items and blocks set the numeric utilities on their roots. Themes add the variant on :root and .tradecn-num, [data-numeric] for consumer markup.
The three-consumer browser matrix checks computed numeric styles on nodes with digits or numeric input values under tradecn slots, plus nodes marked data-numeric. Theme tests also check their numeric base styles. These checks verify the CSS request, not the font's actual glyph shapes; use the specimen below to check those.
Character disambiguation
Compare a candidate font at 12, 13, and 14 px on the background you ship. The specimen uses this page's font. The live demo on tradecn.dev compares the sans and mono stacks with a system serif and offers an accessibility toggle.
To copy the live preview, install format for its helpers, family tokens, and accessibility remap. Install @fontsource/inter, @fontsource/jetbrains-mono, @fontsource/atkinson-hyperlegible-next, and @fontsource/atkinson-hyperlegible-mono too; the preview imports its own fonts. Its local numeric alias makes the decimal column follow the scoped accessibility change described above. The controls affect only the specimen; the serif column keeps its system font.
0O 1lI 5S 8B 69 3-5 4-6 12 px
0O 1lI 5S 8B 69 3-5 4-6 13 px
0O 1lI 5S 8B 69 3-5 4-6 14 px
The NBS handbook summarizes confusions such as 3/5 and 4/6 in experiments on dimly lit numeral displays (pp. 98–100). Those results depend on the designs and viewing conditions; they do not rank current fonts.
Use this checklist at 12 px, with the intended font loaded:
-
0andOare distinct: a slash, a dot, or a clearly narrower zero can help. -
1,l, andIhave distinct shapes, such as a foot or flag on1, a tail onl, and serifs onI. -
5andS, and8andB, remain distinguishable at a glance. -
3and5have distinct tops;4does not close toward6. - The spaces inside
6and9stay clear so neither fills toward0or8. -
1,111.11above8,888.88aligns the decimal points with the numeric variant applied. - Digits have a consistent height and baseline alongside capitals.
- At weight 500 on your dark background, strokes stay distinct.
If alignment or shapes fail, check the loaded family, fallbacks, and computed styles before blaming missing OpenType features. Inter's optional disambiguation alternates are separate from tabular figures; the numeric rule does not enable them.
What we do not recommend
- Gill Sans or Eurostile as a first choice for glance reading. In Sawyer and colleagues' study, Frutiger and FF Meta had the lowest mean display-time thresholds; Gill Sans and Eurostile had the highest. The extremes differed by about 20 ms. This was a word/pseudoword task with 73 adults and white text on black, not a trading-grid test, and it did not include Inter (2020 paper).
- A face whose characters fail the checklist. A geometric classification or an unslashed zero alone does not establish poor legibility.
- Very thin strokes for dense dark screens. Start with the suggested grid weight of 500 and judge the loaded font at its actual size. This is a design choice, not a universal weight threshold.
- Pure
#FFFon#000body text. The body tokens and dark themes use off-white on near-black. This is a preference for long sessions, not a measured finding about fatigue.
A serif face your firm licenses can still be a candidate. Arditi and Cho found no reading-speed effect from adding serifs to their controlled fonts; small size-threshold effects could be explained by spacing. That does not rank all serif and sans families or establish a general reading-speed advantage for wider spacing (study).
Sources
Research
- Sawyer, Dobres, Chahine, and Reimer, 2020, "The great typography bake-off": glance thresholds for eight sans-serif faces.
- Arditi and Cho, "Serifs and font legibility": controlled serif and spacing experiments.
- NBS, "Legibility of Alphanumeric Characters and Other Symbols: II. A Reference Handbook", also archived by DTIC: a collection of earlier reports, not one numeral experiment.
Font documentation
- Inter, JetBrains Mono, and Braille Institute's Atkinson Hyperlegible family.
- CSS Fonts: numeric variants, Google Fonts CSS API, and Fontsource setup.
Further reading
- A fintech typography article summarizing Bringhurst's guidance, a secondary source.
- Bloomberg, how its terminal's designers conceal complexity, a design-history reference.
This page is docs/typography.md at v2.0.0.