Ajuster le texte à son conteneur en CSS pur — sans JavaScript
Tout le monde écrit un hook JavaScript pour réduire le font-size jusqu'à ce que le texte tienne dans un conteneur. Voici une alternative pleinement fonctionnelle et sans JS, à base d'unités de container query, de clamp() et de césure selon la langue — y compris le piège de la cascade qui fait abandonner tant de gens.
Les grands titres d'affichage ont une fâcheuse habitude : le texte qui tient parfaitement dans votre maquette déborde dès que le conteneur se rétrécit ou que la copie s'allonge dans une autre langue. Pendant des années, la solution standard a été JavaScript — mesurer le texte rendu, le comparer à son conteneur et réduire la police jusqu'à ce qu'elle tienne. Presque tout le monde réinvente cela : un hook useFitText maison, une boucle ResizeObserver ou une bibliothèque comme fitty, textFit ou react-textfit.
Cet article présente une alternative pleinement fonctionnelle avec zéro JavaScript : une police qui s'adapte à son conteneur grâce aux unités de container query CSS, plus une coupure de ligne correcte avec césure. Pas de hooks, pas de mesures, pas de décalage de mise en page après l'hydratation.
Le JavaScript que tout le monde écrit
Le schéma est toujours le même. Rendre le texte à une taille de base, lire la largeur du texte et celle de son parent, et si le texte est plus large, diminuer la taille de police et réessayer. Une version minimale ressemble à ceci :
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 };
}Ça marche, mais ça a un coût réel :
- Ça s'exécute après le montage du composant, donc le texte est d'abord peint à la mauvaise taille puis saute — un décalage de mise en page visible et un coup porté au CLS.
- Dans les frameworks avec rendu côté serveur, le serveur envoie une taille et le client la corrige après l'hydratation, ce qui peut provoquer un flash.
- Ça expédie du JavaScript pour quelque chose qui relève fondamentalement de la mise en page.
- La boucle par pas de 1px provoque plusieurs re-rendus et force des lectures de mise en page synchrones — exactement le genre de travail qu'on ne veut pas sur le thread principal.
Pourquoi JavaScript était la réponse — jusqu'à récemment
Il faut être honnête sur la raison d'être de ces hooks. Jusqu'à l'arrivée des container queries dans les navigateurs, CSS ne pouvait tout simplement pas dimensionner une police par rapport à un élément. On pouvait dimensionner par rapport au viewport avec vw, mais un titre dans une barre latérale de 400px et le même titre dans un hero de 1200px obtiendraient une taille identique basée sur vw, car vw ne sait rien du conteneur.
Les unités de longueur de container query ont changé cela. Elles ne sont devenues sûres entre navigateurs que vers 2023, donc tout code d'ajustement de police écrit avant n'avait aucune option CSS et se rabattait sur JavaScript par nécessité. Cette contrainte a disparu — et c'est tout l'objet de cet article.
La brique de base : les unités de container query
Une unité de longueur de container query est un pourcentage de la taille d'un conteneur de requête. Marquez un élément comme conteneur avec container-type: inline-size, et ses descendants peuvent utiliser cqi — où 1cqi vaut 1% de la taille inline du conteneur (sa largeur dans les modes d'écriture horizontaux).
.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;
}Désormais le même titre dans une carte de 400px fait 40px, et dans une carte de 1200px il fait 120px — automatiquement, sans mesure. C'est la pièce qui manquait avant les container queries : une taille de police qui réagit au parent.
Borner la taille avec clamp()
Le cqi brut continuerait de grandir sur les conteneurs énormes et de rétrécir à rien sur les minuscules. Enveloppez-le dans clamp() pour poser un plancher et un plafond — typiquement votre taille mobile comme minimum et votre taille bureau comme 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 */
);
}Lisez-le ainsi : soyez fluide avec le conteneur, mais jamais en dessous de 2rem ni au-dessus de 4.5rem. Le terme du milieu (la pente du cqi) contrôle la vitesse de mise à l'échelle de la police ; les deux bornes sont les tailles de police mobile et bureau de votre design. Si ces tailles vivent dans des custom properties, vous pouvez tout piloter depuis elles :
.title {
--size-desktop: 72;
--size-mobile: 32;
}
.title .fit {
font-size: clamp(
calc(var(--size-mobile) * 1px),
10cqi,
calc(var(--size-desktop) * 1px)
);
}Un piège qui va vous coûter une heure : la cascade
Voici le piège qui fait abandonner les gens et conclure : "les container queries ne marchent pas". Le conteneur se résout correctement, l'unité est la bonne, et pourtant la police ne change jamais de taille. La cause est presque toujours la cascade, pas les container queries.
Le symptôme est sournoisement trompeur. Votre police reste bloquée à son maximum dans chaque conteneur, aussi étroit soit-il — ce qui est exactement le comportement de cqi quand il ne trouve pas de conteneur et retombe sur le viewport. Alors vous allez vérifier l'évident : le conteneur est-il bien configuré ? Vous confirmez que container-type: inline-size est là, vous parcourez le DOM pour vérifier que l'ancêtre est bien le conteneur, vous lisez le container-type calculé de l'élément et il indique inline-size, vous mesurez la largeur du conteneur et elle est correcte. Tout concorde. Et la police reste bloquée. Vous cherchez au mauvais endroit.
Le plus rapide est d'arrêter de faire confiance à votre propre règle et de prouver l'unité indépendamment. Déposez dans le même conteneur une sonde jetable ne contenant qu'une valeur cqi brute, et mesurez-la :
// 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();Si la sonde s'adapte correctement mais que votre vrai élément reste bloqué, voilà tout le diagnostic : les container queries fonctionnent parfaitement, et autre chose écrase votre font-size. C'est la cascade — et le coupable est généralement un sélecteur plus large que vous avez oublié.
Les design systems posent souvent la typographie sur des éléments imbriqués avec une règle comme .title p, .title span { font-size: … }. Ce sélecteur a une spécificité de (0,1,1). Si votre règle d'ajustement vise le même élément avec une seule classe — .fit { … } à (0,1,0) — la règle du design system l'emporte et fige la taille de police, si bien que votre clamp() est silencieusement écrasé. Les deux peuvent même vivre dans la même couche de cascade, donc les layers ne vous sauveront pas. Pire, cette règle plus large était probablement basée sur le viewport, d'où l'impression d'un fallback viewport — vous voyiez littéralement une autre règle gagner.
Le correctif est de donner à votre règle au moins une spécificité égale. L'imbriquer sous la classe du conteneur est le plus simple : .title .fit vaut (0,2,0), ce qui bat .title p à (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); }Si votre taille de police de container query semble ignorée, inspectez l'élément et vérifiez quelle règle pose réellement <code>font-size</code>. Neuf fois sur dix, un sélecteur plus large du design system gagne la cascade — la container query elle-même va bien.
Le problème du mot long — et pourquoi ce n'est pas un échec des container queries
Il y a une chose que cqi ne peut vraiment pas faire : il met la taille de police à l'échelle selon la largeur du conteneur, pas selon la longueur d'une chaîne précise. Un seul mot insécable peut encore être plus large que le conteneur à la taille calculée. L'allemand est le coupable classique : un mot comme Fremdsprachenkenntnisse débordera allègrement d'une boîte étroite même à une taille de police adaptée au conteneur.
Ce n'est pas un bug de l'approche ; c'est sa limite honnête. L'ancien hook JavaScript gérait ce cas en mesurant précisément ce mot et en réduisant davantage. En CSS, vous le gérez autrement — vous laissez le mot se couper au lieu de réduire tout le titre. Deux propriétés font le travail :
.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 est le filet de sécurité : si un mot ne tient pas sur une ligne, le navigateur le coupe au lieu de le laisser déborder. text-wrap: balance est la finition — il égalise les longueurs de ligne pour qu'un titre coupé ne se termine pas par un mot seul et esseulé.
Couper avec un trait d'union — automatiquement, par langue
Couper un mot en plein milieu sans trait d'union fait grossier. CSS peut insérer un vrai trait d'union à un point linguistiquement valide avec hyphens: auto. Le détail à comprendre : la césure dépend de la langue. Le navigateur utilise le dictionnaire de césure de la langue de l'élément, qu'il lit dans l'attribut lang.
.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;
}Le côté élégant : si votre document définit déjà la langue à la racine — <html lang="de"> pour une page en allemand — la césure fonctionne d'elle-même, par langue, sans câblage supplémentaire. Les pages allemandes se coupent avec le dictionnaire allemand, les anglaises avec l'anglais. Vous obtenez des traits d'union corrects dans chaque langue gratuitement, tant que lang reflète le contenu.
Gardez overflow-wrap: break-word à côté. hyphens: auto gère les mots qu'il sait couper ; le fallback attrape tout le reste (URLs, noms de marque, composés inventés) pour que rien ne déborde jamais.
Assembler le tout
Voici le composant complet, sans dépendances. Notez la structure à deux éléments : un élément extérieur qui est le conteneur, et un élément intérieur qui en lit le cqi. Un élément ne peut pas dimensionner sa propre police à partir de son propre conteneur, il faut donc la séparation parent/enfant.
<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;
}Voilà toute la fonctionnalité : un titre qui s'adapte à son conteneur, respecte un plancher mobile et un plafond bureau, ne déborde jamais et se coupe avec des traits d'union corrects dans n'importe quelle langue — et il n'expédie pas un seul octet de JavaScript.
CSS contre JavaScript, en toute honnêteté
| Critère | Hook / bibliothèque JS de fit-text | CSS pur (cqi + clamp) |
|---|---|---|
| Réagit à la largeur du conteneur | Oui (par mesure) | Oui (via cqi) |
| Réagit à la longueur exacte de la chaîne | Oui | Non — borné par overflow-wrap / hyphens à la place |
| Décalage de mise en page après chargement | Fréquent (mesurer puis redimensionner) | Aucun — correct dès le premier paint |
| Fonctionne en SSR avant l'hydratation | Non | Oui |
| JavaScript expédié | Oui | Aucun |
| Coût sur le thread principal | Reflows + re-rendus | Zéro |
| Césure selon la langue | Manuelle | Intégrée via hyphens: auto |
La seule colonne où JavaScript l'emporte encore, c'est l'ajustement exact par chaîne — forcer un titre précis sur exactement une ligne à la plus grande taille qui tient. Si ce comportement précis est une exigence stricte, la mesure (JS, ou un <text> SVG qui s'adapte à un viewBox) reste la seule voie. Mais pour l'immense majorité des titres, le dimensionnement relatif au conteneur rend aussi bien ou mieux — sans aucun de ces coûts.
Prise en charge des navigateurs
Les unités de longueur de container query (cqi et consorts) sont prises en charge par tous les navigateurs evergreen actuels, et ce depuis 2023. clamp(), overflow-wrap, hyphens et text-wrap: balance sont aussi largement disponibles (text-wrap: balance est le plus récent, se dégradant élégamment vers le retour à la ligne normal là où il manque). Pour un fallback sur de très vieux navigateurs, une simple font-size déclarée avant la ligne du clamp() suffit.
À retenir
- Le JavaScript d'ajustement de police que tout le monde écrit existe surtout parce que CSS ne pouvait pas dimensionner la typo par conteneur avant l'arrivée des container queries.
container-type: inline-size+font-size: clamp(min, Ncqi, max)donne une typo relative au conteneur avec un plancher mobile et un plafond bureau.- Si la taille semble ignorée, c'est la cascade — surpassez la spécificité des sélecteurs larges du design system en imbriquant votre règle.
cqis'adapte à la largeur du conteneur, pas à la longueur de la chaîne ; utilisezoverflow-wrap: break-wordethyphens: autopour que les mots longs se coupent proprement au lieu de déborder.hyphens: autodépend de la langue gratuitement quand lelangdu document est correctement défini.- Ne recourez à JavaScript que si vous avez vraiment besoin d'un ajustement exact d'une ligne par chaîne.
C'est désormais publié — deux petits paquets sans dépendances : @oleksiimazurenko/react-fit-text pour React et @oleksiimazurenko/fit-text pour le cœur CSS indépendant du framework (code source sur 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>