Numbers That Stop Jittering: Tailwind v4's font-features Utility

Tailwind font-features exposes raw font-feature-settings in v4.2. When to reach for it, why tabular-nums is usually the better fix, and the real gotchas.

Steven Richardson
Steven Richardson
· 10 min read

A wire:poll counter on a dashboard I was reviewing updated every two seconds, and the entire row shuffled sideways each time it did. Nobody had broken the layout. The font's default digits are proportional, 1 is narrower than 0, and 1,199 becoming 1,200 is genuinely a different width.

The fix is one class. The interesting part is the layer underneath it, because Tailwind v4.2 added font-features-* and most people now reach for the tailwind font-features utility in exactly the cases where the older, higher-level class was the right answer.

The three layers of OpenType control Tailwind exposes#

OpenType fonts ship features as four-character tags — tnum for tabular figures, zero for a slashed zero, ss01 for the first stylistic set. CSS gives you two ways to switch them on, and they are not equivalent.

The high-level way is font-variant-numeric and its siblings. Named values, and crucially they compose: slashed-zero tabular-nums on one element produces font-variant-numeric: slashed-zero tabular-nums, both applied.

The low-level way is font-feature-settings, which takes raw tags. It is a single property with a single value, so it does not compose at all.

Tailwind maps the first onto nine utilities and, since v4.2, the second onto font-features-*:

<!-- High level: composes, this is both features at once -->
<dd class="slashed-zero tabular-nums">$114.50</dd>

<!-- Low level: one property, raw tags -->
<p class="font-features-['ss01']">Brand heading</p>

The CSS Fonts spec is explicit that the high-level properties win when both target the same feature. font-variant-numeric is resolved after font-feature-settings, so tabular-nums beats a font-features-['pnum'] sitting on the same element — the low-level class is not ignored, it is simply overridden for that feature. MDN says the same thing in plainer language: use the font-variant longhands and treat font-feature-settings as the special case.

The tag-to-utility reference table#

Every numeric feature already has a named Tailwind class. If a row exists here, you do not need font-features-*.

OpenType tag What it does Tailwind class
tnum Fixed-width figures tabular-nums
pnum Proportional figures proportional-nums
lnum Lining figures, aligned to the cap height lining-nums
onum Oldstyle figures, with descenders oldstyle-nums
zero Slashed zero slashed-zero
ordn Ordinal markers — the st in 1st ordinal
frac Diagonal fractions diagonal-fractions
afrc Stacked fractions stacked-fractions
Reset all of the above normal-nums
ss01ss20 Stylistic sets font-features-['ss01']
cv01+ Character variants font-features-['cv01']
dlig Discretionary ligatures font-features-['dlig']
calt Contextual alternates (on by default) font-features-['calt'_0]
smcp Small caps font-features-['smcp']

That underscore in font-features-['calt'_0] is Tailwind's arbitrary-value convention: an underscore becomes a space at build time, so the class compiles to font-feature-settings: 'calt' 0. A trailing 0 switches a feature off, which matters because calt is enabled by default in most fonts.

Note the quotes are single. In a double-quoted HTML class attribute that is the only form that survives, and it matches what the Tailwind docs use.

Where Tailwind font-features earns its place#

Four situations, and only four.

Stylistic sets and character variants. There is no font-variant equivalent for ss01. The designer specifies the double-storey a in Figma, the font ships it behind a stylistic set, and before v4.2 the answer was a hand-written CSS file.

Turning a default feature off. calt is on by default. In a code snippet, a font with contextual alternates will happily render != as a single glyph the developer did not write. font-features-['calt'_0] stops it.

Small caps in a font whose font-variant-caps support is patchy. smcp via feature settings is the blunt instrument that works.

Reaching inside a form control. This one is genuinely non-obvious, and it is covered in the gotchas below.

Everything else — every digit-shaped problem on a dashboard — belongs to the named utilities.

Tailwind font-features and the property that does not compose#

Here is the hour-long bug. Two classes, one element:

<!-- Wrong: only one of these survives -->
<h1 class="font-features-['ss01'] font-features-['cv02']">Acme</h1>

font-feature-settings is one property. Two classes generate two declarations of it at identical specificity in the same cascade layer, so the later one in the generated stylesheet wins. Not the later one in your class attribute — Tailwind sorts utilities when it builds the CSS, so which of the two survives is not something you can read off the HTML. You get one feature, and which one is effectively arbitrary.

The fix is one class carrying both tags, comma-separated exactly as the CSS property expects:

<!-- Right: one declaration, both features -->
<h1 class="font-features-['ss01','cv02']">Acme</h1>

This is the concrete reason to prefer the named utilities wherever one exists. slashed-zero tabular-nums oldstyle-nums is three classes that all apply, because they all feed one composable property. The low-level escape hatch has no such mechanism, and nothing warns you.

Registering a named utility instead of bracket soup#

font-features-['ss01','cv02'] scattered through forty Blade templates is a bad time when the brand font is replaced and ss01 becomes ss03. Put the feature string in the theme layer once:

@import "tailwindcss";

@utility brand-alts {
  font-feature-settings: "ss01", "cv02";
}

Then the template reads as intent rather than implementation:

<h1 class="brand-alts text-4xl font-semibold">{{ $heading }}</h1>

One greppable definition, one place to change, and a class name a designer can actually review. The @utility directive and how custom utilities work in v4 covers the variant behaviour you get for free here — md:brand-alts and hover:brand-alts both work without any extra configuration.

Use arbitrary bracket values for a one-off. Register a utility for anything that appears more than twice.

Checking whether your font ships the feature at all#

The single most common report is "the class does nothing", and the cause is almost always the font rather than the CSS. An unsupported OpenType tag is ignored silently — no console warning, no fallback, nothing in devtools except a font-feature-settings declaration that is definitely being applied and definitely changing nothing.

Two ways to check before you start debugging Tailwind:

# On macOS or Linux with fontconfig, dump what the file actually contains
fc-query /path/to/BrandFont.woff2 | grep -i feature

# Or use the foundry's spec sheet — Google Fonts lists supported
# features on each family page under "Glyphs"

The fastest diagnostic is a control. Apply the same class to a font you know supports the feature — most commercial text faces ship tnum and zero — and see whether it takes. If it works there and not on your brand font, the CSS is fine and the font is the problem. System font stacks in particular ship almost nothing beyond the numeric basics.

Recipes worth adopting today#

Small, high-value, no arbitrary values required.

<!-- Any number that updates in place -->
<span class="tabular-nums">{{ number_format($count) }}</span>

<!-- Anything a human reads aloud or types back: order refs, API keys -->
<code class="slashed-zero tabular-nums">{{ $order->reference }}</code>

<!-- Money columns, right-aligned and actually aligned -->
<td class="text-right tabular-nums">{{ $line->formatted_total }}</td>

<!-- Recipe and measurement UIs -->
<span class="diagonal-fractions">1/2 tsp</span>

<!-- Body prose in a serif; lining figures for the heading above it -->
<article class="oldstyle-nums prose"></article>

The table case is where it pays off most. A totals row only reads as a total if the digits above it line up, so if you are rendering column totals with Filament v5 summarizers put tabular-nums on the column and the summary together — a right-aligned proportional column and a right-aligned summary agree at the last digit and disagree everywhere else. The same applies to any striped table built with the nth-* variants, where a misaligned column is much more obvious against alternating row backgrounds.

And the case I opened with: a metric card refreshed with wire:poll needs tabular-nums on the number, not on the card. Digits are the only thing that should be locked to a fixed advance width — the label above it reads better proportional.

Accessibility and copy-paste fidelity#

tabular-nums and slashed-zero are legibility wins with no downside worth discussing. A slashed zero in an order reference genuinely prevents people reading 0 as O over the phone.

Discretionary ligatures and aggressive stylistic sets are a different matter. A ligature is a substitution at the glyph level, so the text content is unchanged — a screen reader announces the underlying characters, and copy-paste yields the original string. That sounds reassuring until the substitution changes what the reader believes they are looking at, which is exactly the != becoming case: the copied text is correct and the rendered text is a lie. Keep dlig and calt away from anything the reader is expected to transcribe.

Text selection also gets awkward around ligated pairs in some browsers, where the highlight covers both characters or neither. Not a blocker, but a reason to keep discretionary features in display type rather than body copy.

Gotchas and Edge Cases#

font-feature-settings is inherited, and reapplying it replaces the whole value. Set font-features-['ss01'] on a page wrapper and every nested <code>, <pre> and caption inherits it. A child that sets its own font-features-* does not merge with the parent's — it replaces it entirely. Reset with font-features-['normal'] where you need the default back, or scope the class tightly in the first place.

Form controls lose font-variant-numeric but keep font-feature-settings. Tailwind's Preflight applies font: inherit to button, input, select, optgroup and textarea, and the font shorthand resets every font-variant-* longhand to normal. Preflight then explicitly re-inherits font-feature-settings and font-variation-settings, but not font-variant-numeric. The practical consequence: a tabular-nums wrapper does not reach an <input> inside it, which is how you end up with a perfectly aligned table sitting under a misaligned quantity field. Put the class on the input itself, or use font-features-['tnum'] on the wrapper, which does inherit through. This is the one place the low-level utility beats the named one on merit — worth checking in devtools on your own stack, and worth remembering when you are already fighting form controls for things like auto-growing textareas with field-sizing.

Dynamically built class strings are never generated. Tailwind scans source files for literal class names. "font-features-['" . $tag . "']" in a Blade helper produces no CSS at all, because the scanner never sees the finished string. Either register a utility per tag or declare them with @source inline() to safelist dynamic Blade classes.

Variable fonts change nothing here. font-feature-settings and font-variation-settings are different properties solving different problems — features are discrete on/off substitutions, variations are continuous axes. Setting one does not touch the other, and neither adds a byte to the download.

normal-nums only resets the high-level property. It maps to font-variant-numeric: normal and leaves any inherited font-feature-settings exactly where it was. Two layers means two resets.

Wrapping Up#

Add tabular-nums to every number that updates in place this afternoon — it costs one class and it is the highest-value typography fix in any dashboard. Save font-features-* for stylistic sets, character variants, disabling calt, and form controls, and register those as @utility definitions rather than scattering bracket values through templates.

If you are auditing table typography while you are in there, wrap-anywhere versus wrap-break-word for long URLs covers the other half of the problem — digits that jitter and strings that overflow are the two failures that make a data table look broken.

FAQ#

What does the font-features utility do in Tailwind CSS?

font-features-* sets the CSS font-feature-settings property, which enables OpenType features by their four-character tag. It was added in Tailwind v4.2 and accepts arbitrary values like font-features-['smcp'], multiple comma-separated tags like font-features-['smcp','onum'], or a CSS variable with font-features-(--my-features). It is the low-level escape hatch for features that have no named Tailwind utility.

How do I get tabular numbers in Tailwind?

Add the tabular-nums class to the element containing the digits. It maps to font-variant-numeric: tabular-nums, which enables the font's tnum feature and gives every digit the same advance width. Put it on the number itself rather than a wrapper, and remember that form inputs need it applied directly because Tailwind's Preflight resets font-variant-numeric on form controls.

What is the difference between font-features and tabular-nums?

tabular-nums is a named utility for the high-level font-variant-numeric property; font-features-* writes the low-level font-feature-settings property directly. The named utilities compose, so you can stack slashed-zero tabular-nums and get both. font-feature-settings is a single property, so two font-features-* classes on one element will not merge. Where a named utility exists, it is the better choice — and per the CSS Fonts spec it also takes precedence if the two conflict.

Why is my font-features class not doing anything?

Almost always because the font file does not ship that feature. An unsupported OpenType tag is ignored silently, with no warning anywhere. Check the font's feature list with fc-query or the foundry's spec sheet, and confirm the CSS is reaching the element by applying the same class to a font you know supports the feature. The other common cause is a second font-features-* class on the same element overriding the first.

How do I enable a stylistic set like ss01 in Tailwind?

Use font-features-['ss01'] for a one-off, or register a named utility in your CSS with @utility brand-alts { font-feature-settings: "ss01"; } and use brand-alts in templates. The registered version is better for anything used more than once — it keeps the feature tag in a single reviewable place and survives a change of brand font without a find-and-replace across every Blade file.

Can I use two font-features classes on the same element?

No. font-feature-settings is a single CSS property, so two classes produce two competing declarations and only one survives. Worse, the winner is decided by the order Tailwind emits the utilities into the stylesheet, not the order you wrote them in the class attribute, so it is not predictable from the markup. Combine the tags into one class instead: font-features-['ss01','cv02'].

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.