Fit text to its container with pure CSS — no JavaScript
Everyone writes a JavaScript hook to shrink font-size until text fits a container. Here's a fully working, zero-JS alternative using container query units, clamp(), and language-aware hyphenation — including the cascade gotcha that makes people give up.
Big display headings have a nagging habit: the text that fits perfectly in your design overflows the moment the container gets narrower or the copy gets longer in another language. For years the standard fix has been JavaScript — measure the rendered text, compare it to its container, and shrink the font until it fits. Almost everyone reinvents this: a homemade useFitText hook, a ResizeObserver loop, or a library like fitty, textFit, or react-textfit.
This article shows a fully working alternative with zero JavaScript: font that scales to its container using CSS container query units, plus proper line breaking with hyphens. No hooks, no measuring, no layout shift after hydration.
The JavaScript everyone writes
The pattern is always the same. Render the text at some base size, read the width of the text and the width of its parent, and if the text is wider, decrease the font size and try again. A minimal version looks like this:
import { useLayoutEffect, useRef, useState } from "react";
// Shrinks font-size by 1px until the text fits its parent.
export function useFitText() {
const parentRef = useRef<HTMLElement>(null);
const textRef = useRef<HTMLElement>(null);
const [fontSize, setFontSize] = useState<number>();
useLayoutEffect(() => {
const parent = parentRef.current;
const text = textRef.current;
if (!parent || !text) return;
const current = parseFloat(getComputedStyle(text).fontSize);
if (parent.offsetWidth < text.offsetWidth) {
setFontSize(current - 1); // re-render, measure again
}
}, [fontSize]);
return { parentRef, textRef, fontSize };
}It works, but it carries real costs:
- It runs after the component mounts, so the text paints at the wrong size first and then jumps — a visible layout shift and a hit to CLS.
- In frameworks with server rendering, the server sends one size and the client corrects it after hydration, which can cause a flash.
- It ships JavaScript for something that is fundamentally a layout concern.
- The 1px-at-a-time loop causes multiple re-renders and forces synchronous layout reads, which is exactly the kind of work you don't want on the main thread.
Why JavaScript was the answer — until recently
It's worth being honest about why these hooks exist. Until container queries shipped across browsers, CSS simply could not size a font relative to an element. You could size relative to the viewport with vw, but a heading inside a 400px sidebar and the same heading inside a 1200px hero would get the identical vw-based size, because vw knows nothing about the container.
Container query length units changed that. They only became safely cross-browser around 2023, so any font-fitting code written before then had no CSS option and reached for JavaScript out of necessity. That constraint is gone now — which is the whole point of this article.
The building block: container query units
A container query length unit is a percentage of a query container's size. Mark an element as a container with container-type: inline-size, and its descendants can use cqi — where 1cqi equals 1% of the container's inline size (its width in horizontal writing modes).
.card {
container-type: inline-size; /* this element is now a query container */
}
.card .title {
/* 10% of .card's width — scales with the container, not the viewport */
font-size: 10cqi;
}Now the same title inside a 400px card is 40px, and inside a 1200px card is 120px — automatically, with no measuring. This is the piece that was missing before container queries: font size that responds to the parent.
Bounding the size with clamp()
Raw cqi would keep growing on huge containers and shrinking to nothing on tiny ones. Wrap it in clamp() to set a floor and a ceiling — typically your mobile size as the minimum and your desktop size as the maximum:
.card {
container-type: inline-size;
}
.card .title {
font-size: clamp(
2rem, /* min — never smaller than the mobile size */
10cqi, /* fluid — scales with the container width */
4.5rem /* max — never larger than the desktop size */
);
}Read it as: be fluid with the container, but never below 2rem and never above 4.5rem. The middle term (the cqi slope) controls how fast the font scales; the two bounds are your design's mobile and desktop type sizes. If those sizes live in custom properties, you can drive everything from them:
.title {
--size-desktop: 72;
--size-mobile: 32;
}
.title .fit {
font-size: clamp(
calc(var(--size-mobile) * 1px),
10cqi,
calc(var(--size-desktop) * 1px)
);
}A gotcha that will cost you an hour: the cascade
Here is the trap that makes people give up and conclude "container queries don't work." The container resolves correctly, the unit is right, and yet the font never changes size. The cause is almost always the cascade, not container queries.
The symptom is deviously misleading. Your font sits pinned at its maximum in every container, no matter how narrow — which is exactly how cqi behaves when it can't find a container and falls back to the viewport. So you go check the obvious thing: is the container set up right? You confirm container-type: inline-size is there, you walk the DOM to verify the ancestor really is the container, you read the element's computed container-type and it says inline-size, you measure the container's width and it's correct. Everything checks out. And the font is still stuck. You are looking in the wrong place.
The fastest way out is to stop trusting your own rule and prove the unit independently. Drop a throwaway probe into the same container with nothing but a raw cqi value, and measure it:
// Inject a bare probe as a child of the same container:
const probe = document.createElement("span");
probe.style.fontSize = "10cqi";
container.append(probe);
getComputedStyle(probe).fontSize;
// 90px inside a 900px container, 36px inside 360px → cqi works fine.
probe.remove();If the probe scales correctly but your real element stays pinned, that is the whole diagnosis: container queries are working perfectly, and something else is overriding your font-size. It's the cascade — and the culprit is usually a broader selector you forgot about.
Design systems often set typography on nested elements with a rule like .title p, .title span { font-size: … }. That selector has a specificity of (0,1,1). If your fit rule targets the same element with a single class — .fit { … } at (0,1,0) — the design-system rule wins and pins the font size, so your clamp() is silently overridden. Both can even live in the same cascade layer, so layers won't save you. Worse, that broader rule was probably viewport-based, which is why the size looked like a viewport fallback — you were literally seeing a different rule win.
The fix is to give your rule at least equal specificity. Nesting it under the container class is the simplest way — .title .fit is (0,2,0), which beats .title p at (0,1,1):
/* Loses to `.title p { font-size: … }` — same layer, higher specificity */
.fit { font-size: clamp(2rem, 10cqi, 4.5rem); }
/* Wins: (0,2,0) > (0,1,1) */
.title .fit { font-size: clamp(2rem, 10cqi, 4.5rem); }If your container query font size seems ignored, inspect the element and check which rule actually sets <code>font-size</code>. Nine times out of ten a broader design-system selector is winning the cascade — the container query itself is fine.
The long-word problem — and why it isn't a container-query failure
There is one thing cqi genuinely cannot do: it scales font size by the container's width, not by the length of a specific string. A single unbreakable word can still be wider than the container at the computed size. German is the classic offender — a word like Fremdsprachenkenntnisse will happily overflow a narrow box even at a container-appropriate font size.
This is not a bug in the approach; it's the honest boundary of it. The old JavaScript hook handled this case by measuring that exact word and shrinking further. In CSS you handle it differently — you let the word break instead of shrinking the whole heading. Two properties do the job:
.title .fit {
font-size: clamp(2rem, 10cqi, 4.5rem);
/* Break a too-long word instead of overflowing the box */
overflow-wrap: break-word;
/* Balance line lengths for nicer multi-line headings */
text-wrap: balance;
}overflow-wrap: break-word is the safety net: if a word cannot fit on a line, the browser breaks it rather than letting it spill out. text-wrap: balance is polish — it evens out line lengths so a wrapped heading doesn't end with a lonely single word.
Breaking with a hyphen — automatically, per language
Breaking a word mid-way without a hyphen looks crude. CSS can insert a proper hyphen at a linguistically valid point with hyphens: auto. The catch worth understanding: hyphenation is language-aware. The browser uses the hyphenation dictionary for the element's language, which it reads from the lang attribute.
.title .fit {
font-size: clamp(2rem, 10cqi, 4.5rem);
hyphens: auto; /* insert a real hyphen at valid break points */
overflow-wrap: break-word; /* last-resort break for words with no valid point */
text-wrap: balance;
}The elegant part: if your document already sets the language on the root — <html lang="de"> for a German page — hyphenation just works, per locale, with no extra wiring. German pages hyphenate with the German dictionary, English with the English one. You get correct hyphens in every language for free, as long as lang reflects the content.
Keep overflow-wrap: break-word alongside it. hyphens: auto handles words it knows how to hyphenate; the fallback catches anything left over (URLs, brand names, made-up compounds) so nothing ever overflows.
Putting it together
Here is the complete, dependency-free component. Note the two-element structure: an outer element that is the container, and an inner element that reads cqi from it. An element cannot size its own font from its own container, so you need the parent/child split.
<h2 class="title" style="--size-desktop: 72; --size-mobile: 32">
<span class="fit">Learn anything, beautifully</span>
</h2>.title {
/* The container the inner text scales against */
container-type: inline-size;
}
/* Nested selector so it out-specifies design-system typography rules */
.title .fit {
display: block;
/* Fluid between the mobile and desktop sizes, driven by container width */
font-size: clamp(
calc(var(--size-mobile) * 1px),
10cqi,
calc(var(--size-desktop) * 1px)
);
/* Never overflow: break long words, hyphenate by language, balance lines */
overflow-wrap: break-word;
hyphens: auto;
text-wrap: balance;
}That is the entire feature: a heading that scales with its container, respects a mobile floor and a desktop ceiling, never overflows, and breaks with correct hyphens in any language — and it ships not a single byte of JavaScript.
CSS vs JavaScript, honestly
| Concern | JS fit-text hook / library | Pure CSS (cqi + clamp) |
|---|---|---|
| Responds to container width | Yes (via measurement) | Yes (via cqi) |
| Responds to exact string length | Yes | No — bounded by overflow-wrap / hyphens instead |
| Layout shift after load | Common (measure-then-resize) | None — correct on first paint |
| Works during SSR before hydration | No | Yes |
| JavaScript shipped | Yes | None |
| Main-thread cost | Reflows + re-renders | Zero |
| Language-aware hyphenation | Manual | Built in via hyphens: auto |
The one column where JavaScript still wins is exact per-string fitting — forcing a specific headline onto exactly one line at the largest size that fits. If that precise behavior is a hard requirement, measurement (JS, or an SVG <text> that scales to a viewBox) is still the only way. For the vast majority of display headings, though, container-relative sizing looks as good or better, with none of the cost.
Browser support
Container query length units (cqi and friends) are supported in all current evergreen browsers and have been since 2023. clamp(), overflow-wrap, hyphens, and text-wrap: balance are widely available too (text-wrap: balance is the newest, degrading gracefully to normal wrapping where absent). For a fallback on very old browsers, a plain font-size declared before the clamp() line is enough.
Takeaways
- The font-fitting JavaScript everyone writes exists mostly because CSS couldn't size type by container until container queries shipped.
container-type: inline-size+font-size: clamp(min, Ncqi, max)gives container-relative type with a mobile floor and desktop ceiling.- If the size seems ignored, it's the cascade — out-specify broad design-system selectors by nesting your rule.
cqiscales by container width, not string length; useoverflow-wrap: break-wordandhyphens: autoso long words break cleanly instead of overflowing.hyphens: autois language-aware for free when the document'slangis set correctly.- Reach for JavaScript only when you truly need exact one-line-per-string fitting.
It's now published as two tiny, dependency-free packages: @oleksiimazurenko/react-fit-text for React, and @oleksiimazurenko/fit-text for the framework-agnostic CSS core (source on GitHub):
npm install @oleksiimazurenko/react-fit-textimport { FitText } from '@oleksiimazurenko/react-fit-text'
import '@oleksiimazurenko/fit-text/style.css'
// No props needed — scales to its container with sensible defaults.
<FitText>Learn anything, beautifully</FitText>