When a framework ships a typography system, the temptation is to make it loud: a seven-step type scale, a fluid ramp, a Bootstrap-style utility for every heading level. The result is a system that wants to be everywhere — and a UI that ends up shaped by accident.
The quieter choice is to make the global baseline do almost nothing. The baseline should give readable defaults for plain HTML, leave the visual hierarchy to the components and the prose scope, and avoid exposing more tokens than theme work has earned.
Why the baseline should stay mild
The first reason is reach. A global h1 rule touches a card title in a sidebar, a dialog
heading, a section label in a dashboard, and a hero banner on a marketing page. If the rule says
large, every one of those becomes large. If the rule says inherit, the
component keeps its own design.
The second reason is rhythm. A typography system that scales every heading by viewport width is great on a hero and miserable in a form. The decision to scale is a decision about surface — and that decision belongs to the surface, not to the framework.
.prose, optional fluid module.Three scopes, three jobs
A typography system stays small when it has clear boundaries. The framework this article ships with uses three.
- Baseline — applies everywhere. Owns the minimum needed to make plain HTML
readable: a tight line-height on headings, a
text-wrap: balancewhere supported, thehrrule, the::selectionrule, the code font family, thesmallfont size. .prose— opt-in rich text scope. Owns the visual hierarchy of authored content: heading sizes, weights, rhythm, link affordance, list spacing, blockquote and figure treatment, and table styling for prose tables.- Optional fluid module — not imported by default. Exposes
--fluid-display,--fluid-title, and--fluid-lead, plus the composed.text-display,.text-title, and.text-leadclasses. For landing pages, hero sections, and documentation homepages.
A small example
Compare a global heading reset to a scoped one. The first is loud; the second stays out of the way.
/* Loud: shapes app UI */
h1, h2, h3, h4, h5, h6 {
font-size: var(--fs-1);
font-weight: 700;
line-height: 1.15;
margin-block: 1.5em 0;
}
/* Quiet: scoped to prose, leaves app UI alone */
:where(h1, h2, h3, h4, h5, h6) {
color: var(--heading, inherit);
line-height: var(--line-height-tight);
}
@supports (text-wrap: balance) {
:where(h1, h2, h3, h4, h5, h6) {
text-wrap: balance;
}
}
The second version does two things and stops. The first version does four things and reaches into every card, dialog, and table heading in the app.
What it means in practice
In practice, "quiet defaults" means a few concrete choices that compose well.
- Global
ainherits color..prose agets the underline. Components that need link semantics style themselves. - Global headings get a tight line-height and
text-wrap: balancewhere supported — nothing more. Size and weight live in.proseor in the component. - The
hgroupelement is structural only: a grid, a gap, a margin reset. No font-size, no color. - No
transition: all. Transitions target specific properties. - No
--font-weight-light. Light weights depend on the typeface and rarely make a good default. - No
text-align: centerhidden inside.text-balance. Balance is about wrapping, not alignment.
When the rule applies
The split between scopes is not just a code organization. It maps to a recurring decision: is this content authored, or is it UI?
| Surface | Scope | Reason |
|---|---|---|
| Article, blog post, docs | .prose |
Authored content benefits from heading hierarchy, links, lists, and figure treatment. |
| Card title, dialog title | Component | Component owns its own typography; .prose would over-shape it. |
| Hero banner, landing page | Optional fluid module | Display text should scale with the viewport — and only here. |
| Nav link, breadcrumb, tab | Component | Link semantics are owned by the component, not by .prose. |
The table is also a useful sanity check. If the same surface would feel right in both an app shell and a marketing page, the typography probably belongs to the component, not to a global rule.
Good defaults should be quiet, not invisible. The user feels the system when it is absent — because nothing is fighting the design.
What we left out
The system above does not include a public type scale. There is no fs-1 to
fs-7, no heading-level utility class, no text-h1 ladder. We resisted
because adding a public scale is a contract, and we want theme work and real component use to
earn the contract before locking it in.
When a scale is needed, the optional fluid module is the right place to start. A future theme can override its tokens, not invent a parallel scale inside components.
Closing
A typography system earns its keep by being invisible. The user does not think about the line height of a heading. They do not notice the wrap balance on a paragraph. They read, they scan, they move on.
The framework's job is to be the thing that lets the user do that — and to stay out of the way when the component, the theme, or the page wants to do something specific. Quiet defaults is not a stylistic choice. It is the surface that the rest of the system composes on.
3 thoughts
Join the conversation — comments are open for two weeks.
The split between
.proseand component-level typography is what made this click for me. We were fighting prose for a year.Curious how this holds up with right-to-left content. The
text-wrap: balancepart especially.@Rin —
text-wrap: balancefollows the inline direction by spec, so it works the same in RTL. The optional fluid module uses logical properties too. Good question.