The audit that kicked it off
Every codebase that's been around a while picks up typographic debt, and mine
had three competing systems that couldn't agree on what an <h2> should look
like:
| Layer | h1 | h2 | h3 |
|---|---|---|---|
| theme.css | 2.25rem / 600 | 1.75rem / 600 | 1.375rem / 600 |
| mdx-components.tsx | text-2xl bold | text-lg semibold | text-sm semibold |
| Page components | text-4xl sm:text-5xl bold | varies | varies |
The same "card title" pattern (text-sm font-semibold text-text-primary)
showed up in 5+ files, hero headings were copy-pasted across 6 pages with
identical 94-character class strings, and MDX h3 and h4 were visually
indistinguishable since both were text-sm font-semibold.
Designing the variant API
I wanted a component API that encodes every approved text style as a named variant, with sensible defaults for the rendered HTML element:
<Heading variant="hero">Page Title</Heading> // → <h1>
<Heading variant="cardTitle" as="p">Label</Heading> // → <p>
<Text variant="body">Paragraph</Text> // → <p>
<Text variant="meta">Apr 5, 2026</Text> // → <span>Each variant maps to a fixed set of Tailwind classes. The as prop overrides
the default element when the semantic context calls for it, like a card title
that isn't actually a heading, and className allows one-off extensions via
cn() merging.
Shared-UI vs app-specific
The architecture decision that mattered was what belongs where. The
shared-ui library (@danieljoffe/shared-ui) can't depend on Next.js, so any
variant tied to page layout or MDX rendering had to stay in the app.
Shared-ui variants (framework-agnostic):
Heading:section,cardTitle,componentText:body,bodyLg,cardDescription,label,meta,caption,helper,error
App-level extensions (Next.js-specific):
Heading:hero,detail,subtitle,mdxH1–mdxH4Text:subtitle
The app-level components hand the shared variants off to the shared-ui implementations and only deal with the app-specific ones locally:
export function Heading({ variant, ...props }: HeadingProps) {
if (sharedVariants.has(variant)) {
return <SharedHeading variant={variant} {...props} />;
}
// Handle app-specific variants
}Fixing under-styled shared components
The audit turned up several shared-ui components with thin typography:
- Modal title: a bare
<h3>with zero styling classes - Alert title: only
mb-1 mt-0, no font size or weight - CardTitle:
text-lgin shared-ui buttext-smeverywhere in the app - Toast: inline
text-sm font-mediuminstead of using the system
I updated each of them to use the new typography components internally.
CardTitle was the one that moved the most: switching from text-lg to
text-sm via <Heading variant="cardTitle"> lined the library up with how
it was actually used.
The MDX heading fix
One quiet win: MDX h4 was visually identical to h3, both
text-sm font-semibold. The new mdxH4 variant uses
text-xs font-medium uppercase tracking-wider, a distinct treatment that
signals a different level in the content hierarchy.
// mdx-components.tsx — now uses the typography system
h3: props => <Heading variant="mdxH3" id={headingId(props)}>{props.children}</Heading>,
h4: props => <Heading variant="mdxH4" id={headingId(props)}>{props.children}</Heading>,Results
- 32 files changed, +439 / -150 lines
- 0 remaining raw hero/section/cardTitle inline patterns
- 618/618 tests passing, zero type errors
- MDX h4 now visually distinct from h3
- Modal, Alert, Toast, and form components all properly styled
The typography system wiped out a whole category of inconsistency. A new page
now starts with <Heading variant="hero"> instead of copying a 94-character
class string out of another file, and when the design moves, one variant
definition updates every instance at once.
