Typography should be utilities.
Typography utilities work best when they describe visual roles and scales, not HTML tags. That keeps semantic markup independent and gives the type system room to grow.
Typography systems usually start with good intent and slowly become annoying.
Someone notices that headings are inconsistent. A few pages use text-2xl. A few use text-3xl. Paragraphs have three slightly different line heights. So the team makes components.
<H1>Dashboard</H1>
<P>Revenue increased this week.</P>
That feels tidy at first. It gives the design system a place to live and makes the happy path easy.
Then real interface work shows up.
A card title should look small, but it needs to be an h2 because it is the next heading in the page outline. A hero uses the same h1 semantics as an app screen, but it should be much larger. A label, legend, button, or output needs a text style that was only ever exposed through a paragraph or heading component.
The problem is not that components are bad. The problem is that they often combine two decisions that need to stay independent: what an element means and how it looks.
Semantic HTML should stay in charge
The browser already gives us meaningful text elements. We should use them.
h1 through h6 describe document structure. p describes paragraphs. label connects a control to its name. legend names a fieldset. output represents a result. time gives a date machine-readable shape.
Heading level is about nesting, not how impressive the heading should look.
That means the semantic decision belongs in the element and the visual decision belongs somewhere else.
<h2 className="text-heading-lg">Billing details</h2>
<p className="text-body-md text-muted-foreground">
Update the payment method used for future invoices.
</p>
<output className="text-metric-lg" aria-label="Monthly recurring revenue">
$42,180
</output>
The element says what the thing is. The class says where it sits in the visual system.
This distinction matters because design and document hierarchy regularly disagree. A mockup may say "heading 3" while the page outline needs an h2. A design may say "body small" while the element is a button, caption, or status message.
Typography utilities let both decisions be correct.
text-h1 is okay, but it still carries baggage
Moving from <H1> to text-h1 is already an improvement. The style can be used on any element, and the DOM is no longer hidden behind a wrapper.
But the name still borrows meaning from an HTML tag. That becomes obvious as soon as the class and element disagree.
<h2 className="text-h3">Payment method</h2>
There is nothing technically wrong with this. The h2 provides the correct semantics and text-h3 provides the intended style. Still, the API makes a normal design-system choice read like a contradiction.
I prefer names that describe a visual role and scale instead.
<h2 className="text-heading-md">Payment method</h2>
Now neither name is pretending to determine the other. The element owns the document hierarchy. The utility owns the visual hierarchy.
Name the role, then the scale
A useful convention is text-{role}-{scale}.
Roles might include heading, body, label, metric, and code. Scales might include xs, sm, md, lg, xl, and 2xl. You do not need every possible combination. You only need the combinations your product actually uses.
A small public set could look like this:
text-heading-2xl,text-heading-xl,text-heading-lg,text-heading-mdtext-body-lg,text-body-md,text-body-smtext-label-md,text-label-smtext-metric-xl,text-metric-lgtext-code-md,text-code-sm
This also gives the scale somewhere to grow.
text-h1 implies there is one visual treatment for an h1. Real products have dense settings screens, editorial pages, marketing pages, and giant hero sections. They may all contain a perfectly valid h1, but they do not need the same typography.
<h1 className="text-heading-xl md:text-heading-2xl">
Build the right thing faster
</h1>
<h1 className="text-heading-lg">Account settings</h1>
No invented h0. No text-hero-h1. Just a larger or smaller step in the heading scale.
If display type is genuinely a different role with its own family or treatment, call it text-display-xl. Otherwise, a larger heading token is usually enough.
Tailwind already gives us the mechanism
shadcn/ui's theming model exposes semantic tokens as utilities like bg-background, text-foreground, and border-border. The token is available to a component, a custom view, or a one-off interface without being trapped inside any of them.
Typography can work the same way.
Tailwind supports custom --text-* theme variables, including default line height, letter spacing, and font weight. The token name becomes the utility name.
@theme inline {
--text-heading-xl: 3rem;
--text-heading-xl--line-height: 1.05;
--text-heading-xl--letter-spacing: -0.03em;
--text-heading-xl--font-weight: 700;
--text-heading-lg: 2.25rem;
--text-heading-lg--line-height: 1.1;
--text-heading-lg--letter-spacing: -0.02em;
--text-heading-lg--font-weight: 700;
--text-body-md: 1rem;
--text-body-md--line-height: 1.65;
--text-body-md--letter-spacing: 0;
--text-body-md--font-weight: 400;
}
That creates text-heading-xl, text-heading-lg, and text-body-md. Font families can remain separate utilities such as font-display, font-sans, and font-mono.
<h1 className="font-display text-heading-xl">Settings</h1>
<p className="font-sans text-body-md text-muted-foreground">No changes yet.</p>
For many teams, that is enough.
If a role also needs a family, balanced wrapping, text transformation, or numeric features, Tailwind's @utility directive can expose the complete treatment behind one class.
@utility text-metric-lg {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
line-height: 1;
}
Pick one owner for each class name. If a --text-* theme variable generates a utility, do not also define that same utility with @utility.
Components still have a job
This is not an argument against typography components.
A prose renderer can own article rhythm. A Hero can compose responsive type, layout, and imagery. A StatCard can decide that its value uses text-metric-lg. A polymorphic heading component can still be convenient when it accepts semantic and visual choices separately.
The difference is that components should compose typography tokens, not be the only way to access them.
If the only way to get a paragraph style is <P>, paragraph styling becomes awkward on anything that is not a paragraph. If the only way to get a heading style is <H3>, visual hierarchy becomes coupled to document hierarchy.
The DOM should carry meaning. Utilities should carry repeatable styling decisions. Components should carry behavior, structure, and product-specific composition.
The best design-system APIs are boring in use.
<h2 className="text-heading-md">Payment method</h2>
<p className="text-body-md text-muted-foreground">Used for all active seats.</p>
<output className="text-metric-lg">$128.00</output>
Semantic nodes first. Role-and-scale utilities everywhere.