---
title: Typography
description: The type scale as components: Heading with outline and size separated, the Label eyebrow, the Lead. One decision, made once.
source: https://adamjurek.com/components/typography
---

## Home


The type scale as four components instead of memorised class names: `Heading`
(with its outline level and visual size kept separate), `Label` for the
eyebrow above a section, `Lead` for the paragraph under a title, and `Text`
for body copy elsewhere. Reach for these instead of an ad-hoc `<p>` with a
font-size class.

<!-- ::start:showcase demo="typography" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The scale itself already lived in atoms, as CSS variables and classes. What
kept going wrong wasn't the scale - it was the reaching for it: every page
re-decided which class a heading takes, and whether the eyebrow above it is a
`Label`. These four components make that decision once, so a page built from
them can't disagree with the next one about what a title is. `as` and `size`
are separate for the same reason: the outline is for screen readers and the
doc aside, and it breaks the moment a page picks `h3` just because it wanted
the smaller font.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/typography.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/typography.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Text |
| Files | `typography.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | typography, heading, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Heading, Label, Lead } from "@sushindustries/ui";

export function Example() {
	return (
		<>
			<Label>Eyebrow</Label>
			<Heading as="h3" size="h2">A heading</Heading>
			<Lead>The paragraph under it.</Lead>
		</>
	);
}
```

## What you should see

Three lines, stacked: a small uppercase eyebrow in mono, a heading at the
`h2` size scale even though it rendered as an `h3` tag, and a dimmed
paragraph under it, capped to a readable measure rather than running the
full width of its container.

## If nothing happens

If everything renders at the browser's default size and weight instead of
this site's type scale, the atoms stylesheet - `--t-h1` through `--t-xs`,
`.h*`, `.label`, `.prose` - is not loaded. These components are wrappers
around that scale; they hold no font sizes of their own.


## Guides


## Outline and size are separate on purpose

`as` picks the tag - the position in the document outline that a screen
reader or `DocAside` reads. `size` picks the look. They default together
(`h2` reads as `h2`-sized), but nothing stops `as="h4" size="h2"` for a
heading that must nest four levels deep in the outline while still reading
as the page's biggest type.

```tsx
<Heading as="h3" size="h2">Section title</Heading>
```

Collapsing the two into one prop is the mistake this exists to prevent: a
page picks its `h3` because it wants the smaller font, and the outline is
wrong for anyone not reading the pixels.

## Four components, four jobs

| | Job |
| --- | --- |
| `Heading` | The title itself. |
| `Label` | The eyebrow above it: mono, small caps, quiet, an optional `icon` that is `aria-hidden` because it repeats the word beside it. |
| `Lead` | The paragraph directly under a heading: dimmed, measured, never full-bleed. |
| `Text` | Body copy anywhere else, with its own `size` and `tone`, and an `inline` flag for sitting inside a sentence rather than starting a paragraph. |

Reaching for a bare `<p>` with an ad-hoc font-size class instead of `Text` is
the drift these exist to stop - it disagrees with the next page's version of
the same idea.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `as?` | `HeadingTag` | `"h2"` | Position in the document outline. |
| `size?` | `"h1" \| "h2" \| "h3"` | - | Visual size, defaulting to the tag's own. |
| `children` | `ReactNode` | - |  |

### LabelProps

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `children` | `ReactNode` | - |  |
| `icon?` | `IconName` | - | A glyph before the words. An eyebrow is four or five uppercase characters at the smallest size on the page, which is the hardest thing on it to scan. A glyph gives the section a shape that is recognisable before the word is read, and on a page of several sections that is the difference between a list of headings and a set of places. Optional, because an eyebrow with nothing meaningful to draw is better with no glyph than with a decorative one. |

### LeadProps

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `children` | `ReactNode` | - |  |

### TextProps

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `children` | `ReactNode` | - |  |
| `size?` | `"xs" \| "sm" \| "md" \| "lg"` | `"md"` | Body sizes from the scale. |
| `tone?` | `"default" \| "dim" \| "faint"` | `"default"` | How loud: default ink, dimmed, or faint. |
| `inline?` | `boolean` | - | Render as a span for inline use. |

<!-- /generated:api -->

## Notes

`Heading`'s `size` defaults to whatever `as` is - passing `as="h4"` with no
`size` renders at the `h4` visual scale. Set `size` only when the tag and the
look need to disagree.

`Label`'s `icon` is rendered with `aria-hidden="true"` unconditionally: it is
decoration next to a word a screen reader already gets from `children`, and
announcing both would read the section name twice.

`Text`'s `tone` and `size` are independent scales - `tone="faint" size="lg"`
is a real combination, not a contradiction. There is no `tone` on `Heading`
or `Lead`; a heading is always full ink and a lead is always dimmed, because
neither has needed a second reading yet.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="typography" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

The eyebrow, heading and lead paragraph that open most sections on this
site, as one unit:

```tsx
import { Heading, Label, Lead, Text } from "@sushindustries/ui";

export function SectionIntro() {
	return (
		<header>
			<Label icon="layers">Components</Label>
			<Heading as="h2">Everything the site is built from</Heading>
			<Lead>
				Every visible element in this library, installable one at a time.
			</Lead>
			<Text tone="dim" size="sm">
				Updated as components graduate out of the app.
			</Text>
		</header>
	);
}
```

## What this example is not

`Heading` here defaults to `as="h2"` with no explicit `size`, so it is
correct only as the section's own top-level title. Nesting a second `header`
like this inside the section would need `as="h3"` to keep the outline
truthful, even though the eyebrow-heading-lead shape stays the same.
