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

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:

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

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

Font documentation

Further reading

This page is docs/typography.md at v2.0.0.