Potrivește textul în containerul său cu CSS pur — fără JavaScript
Toată lumea scrie un hook JavaScript care micșorează font-size până când textul încape în container. Iată o alternativă complet funcțională și fără JS, folosind unități de container query, clamp() și despărțire în silabe în funcție de limbă — inclusiv capcana cascadei care îi face pe mulți să renunțe.
Titlurile mari de tip display au un obicei enervant: textul care se potrivește perfect în design-ul tău dă pe dinafară de îndată ce containerul se îngustează sau textul devine mai lung în altă limbă. Ani la rând soluția standard a fost JavaScript — măsori textul randat, îl compari cu containerul și micșorezi fontul până încape. Aproape toți reinventează asta: un hook useFitText făcut în casă, o buclă ResizeObserver sau o bibliotecă precum fitty, textFit ori react-textfit.
Acest articol arată o alternativă complet funcțională cu zero JavaScript: font care se scalează la containerul său folosind unitățile CSS de container query, plus o rupere corectă a rândurilor cu cratimă. Fără hook-uri, fără măsurători, fără salt de layout după hidratare.
JavaScript-ul pe care îl scrie toată lumea
Tiparul e mereu același. Randezi textul la o dimensiune de bază, citești lățimea textului și pe cea a părintelui, iar dacă textul e mai lat, scazi dimensiunea fontului și încerci din nou. O versiune minimă arată așa:
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 };
}Funcționează, dar are costuri reale:
- Rulează după ce componenta e montată, așa că textul e desenat mai întâi la dimensiunea greșită și apoi sare — un salt de layout vizibil și o lovitură pentru CLS.
- În framework-uri cu randare pe server, serverul trimite o dimensiune, iar clientul o corectează după hidratare, ceea ce poate provoca o pâlpâire.
- Livrează JavaScript pentru ceva care, în esență, ține de layout.
- Bucla care merge din 1px în 1px provoacă mai multe re-randări și forțează citiri sincrone de layout — exact genul de muncă pe care n-o vrei pe firul principal.
De ce JavaScript era răspunsul — până de curând
Merită să fim sinceri de ce există aceste hook-uri. Până când container queries au ajuns în browsere, CSS pur și simplu nu putea dimensiona un font relativ la un element. Puteai dimensiona relativ la viewport cu vw, dar un titlu într-o bară laterală de 400px și același titlu într-un hero de 1200px ar primi dimensiunea identică bazată pe vw, fiindcă vw nu știe nimic despre container.
Unitățile de lungime ale container query au schimbat asta. Au devenit sigure între browsere abia pe la 2023, așa că orice cod de potrivire a fontului scris înainte nu avea opțiune în CSS și recurgea la JavaScript din necesitate. Constrângerea aceea a dispărut acum — și despre asta e tot articolul.
Cărămida de bază: unitățile de container query
O unitate de lungime a container query este un procent din dimensiunea unui container de interogare. Marchează un element drept container cu container-type: inline-size, iar descendenții săi pot folosi cqi — unde 1cqi înseamnă 1% din dimensiunea inline a containerului (lățimea sa în modurile de scriere orizontale).
.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;
}Acum același titlu într-un card de 400px are 40px, iar într-unul de 1200px are 120px — automat, fără măsurători. Aceasta e piesa care lipsea înainte de container queries: o dimensiune de font care răspunde părintelui.
Mărginirea dimensiunii cu clamp()
Un cqi gol ar continua să crească pe containere uriașe și s-ar micșora până la nimic pe cele minuscule. Împachetează-l în clamp() ca să fixezi un prag minim și unul maxim — de obicei dimensiunea de mobil ca minim și cea de desktop ca maxim:
.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 */
);
}Citește-l așa: fii fluid cu containerul, dar niciodată sub 2rem și niciodată peste 4.5rem. Termenul din mijloc (panta cqi) controlează cât de repede se scalează fontul; cele două limite sunt dimensiunile de font pentru mobil și desktop din design-ul tău. Dacă acele dimensiuni stau în custom properties, poți conduce totul de la ele:
.title {
--size-desktop: 72;
--size-mobile: 32;
}
.title .fit {
font-size: clamp(
calc(var(--size-mobile) * 1px),
10cqi,
calc(var(--size-desktop) * 1px)
);
}O capcană care te va costa o oră: cascada
Iată capcana care îi face pe oameni să renunțe și să conchidă: "container queries nu funcționează". Containerul se rezolvă corect, unitatea e cea potrivită, și totuși fontul nu-și schimbă niciodată dimensiunea. Cauza e aproape întotdeauna cascada, nu container queries.
Simptomul e viclean de înșelător. Fontul tău stă țintuit la maximul său în fiecare container, oricât de îngust ar fi — ceea ce e exact cum se comportă cqi când nu găsește un container și recade pe viewport. Așa că te apuci să verifici evidentul: e containerul configurat corect? Confirmi că container-type: inline-size e acolo, parcurgi DOM-ul ca să verifici că strămoșul chiar e containerul, citești container-type-ul calculat al elementului și spune inline-size, măsori lățimea containerului și e corectă. Totul se potrivește. Și fontul e tot țintuit. Cauți în locul greșit.
Calea cea mai rapidă e să încetezi să te încrezi în propria regulă și să dovedești unitatea independent. Aruncă în același container o sondă de unică folosință care conține doar o valoare cqi brută, și măsoar-o:
// 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();Dacă sonda se scalează corect, dar elementul tău real rămâne țintuit, ăsta e tot diagnosticul: container queries funcționează perfect, iar altceva îți suprascrie font-size-ul. E cascada — iar vinovatul e de obicei un selector mai larg pe care l-ai uitat.
Sistemele de design setează adesea tipografia pe elemente imbricate cu o regulă precum .title p, .title span { font-size: … }. Acel selector are o specificitate de (0,1,1). Dacă regula ta de potrivire țintește același element cu o singură clasă — .fit { … } la (0,1,0) — regula sistemului de design câștigă și țintuiește dimensiunea fontului, așa că clamp()-ul tău e suprascris în tăcere. Ambele pot chiar sta în același strat de cascadă, deci layer-ele nu te salvează. Mai rău, acea regulă mai largă era probabil bazată pe viewport, motiv pentru care dimensiunea arăta ca un fallback de viewport — vedeai literalmente cum câștigă altă regulă.
Remedierea e să-i dai regulii tale cel puțin o specificitate egală. Imbricarea ei sub clasa containerului e cel mai simplu — .title .fit e (0,2,0), care bate .title p la (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); }Dacă dimensiunea fontului din container query pare ignorată, inspectează elementul și verifică ce regulă setează de fapt <code>font-size</code>. De nouă ori din zece, un selector mai larg din sistemul de design câștigă cascada — container query în sine e în regulă.
Problema cuvântului lung — și de ce nu e un eșec al container query
Există un lucru pe care cqi chiar nu-l poate face: scalează dimensiunea fontului după lățimea containerului, nu după lungimea unui șir anume. Un singur cuvânt indivizibil poate fi tot mai lat decât containerul la dimensiunea calculată. Germana e vinovatul clasic — un cuvânt precum Fremdsprachenkenntnisse va da voios pe dinafara unei casete înguste chiar și la o dimensiune de font potrivită containerului.
Nu e un bug al abordării; e limita ei onestă. Vechiul hook JavaScript rezolva acest caz măsurând exact acel cuvânt și micșorând mai departe. În CSS îl rezolvi altfel — lași cuvântul să se rupă în loc să micșorezi tot titlul. Două proprietăți fac treaba:
.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 e plasa de siguranță: dacă un cuvânt nu încape pe un rând, browserul îl rupe în loc să-l lase să dea pe dinafară. text-wrap: balance e finisajul — egalizează lungimile rândurilor, ca un titlu rupt să nu se termine cu un singur cuvânt răzleț.
Ruperea cu cratimă — automat, per limbă
Ruperea unui cuvânt la mijloc fără cratimă arată grosolan. CSS poate insera o cratimă adevărată într-un punct valid lingvistic cu hyphens: auto. Detaliul de înțeles: despărțirea în silabe ține cont de limbă. Browserul folosește dicționarul de despărțire pentru limba elementului, pe care o citește din atributul 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;
}Partea elegantă: dacă documentul tău setează deja limba pe rădăcină — <html lang="de"> pentru o pagină în germană — despărțirea pur și simplu funcționează, per limbă, fără cablaj suplimentar. Paginile în germană se despart cu dicționarul german, cele în engleză cu cel englez. Primești cratime corecte în fiecare limbă gratis, atât timp cât lang reflectă conținutul.
Păstrează overflow-wrap: break-word alături. hyphens: auto se ocupă de cuvintele pe care știe să le despartă; fallback-ul prinde restul (URL-uri, nume de brand, compuse inventate), ca nimic să nu dea vreodată pe dinafară.
Punând totul cap la cap
Iată componenta completă, fără dependențe. Observă structura din două elemente: un element exterior care e containerul, și unul interior care citește cqi din el. Un element nu-și poate dimensiona propriul font din propriul container, așa că ai nevoie de împărțirea părinte/copil.
<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;
}Asta e toată funcționalitatea: un titlu care se scalează cu containerul, respectă un prag de mobil și un plafon de desktop, nu dă niciodată pe dinafară și se rupe cu cratime corecte în orice limbă — și nu livrează nici măcar un octet de JavaScript.
CSS versus JavaScript, sincer
| Aspect | Hook / bibliotecă JS de fit-text | CSS pur (cqi + clamp) |
|---|---|---|
| Răspunde la lățimea containerului | Da (prin măsurare) | Da (prin cqi) |
| Răspunde la lungimea exactă a șirului | Da | Nu — mărginit în schimb prin overflow-wrap / hyphens |
| Salt de layout după încărcare | Frecvent (măsoară apoi redimensionează) | Niciunul — corect la primul paint |
| Funcționează la SSR înainte de hidratare | Nu | Da |
| JavaScript livrat | Da | Niciunul |
| Cost pe firul principal | Reflow-uri + re-randări | Zero |
| Despărțire în silabe per limbă | Manuală | Încorporată prin hyphens: auto |
Singura coloană unde JavaScript încă învinge e potrivirea exactă per șir — a forța un titlu anume pe exact un rând, la cea mai mare dimensiune care încape. Dacă acest comportament precis e o cerință fermă, măsurarea (JS, sau un <text> SVG care se scalează la un viewBox) rămâne singura cale. Dar pentru marea majoritate a titlurilor de display, dimensionarea relativă la container arată la fel de bine sau mai bine — fără niciunul dintre costuri.
Suport în browsere
Unitățile de lungime ale container query (cqi și rudele sale) sunt suportate în toate browserele evergreen actuale, și așa e din 2023. clamp(), overflow-wrap, hyphens și text-wrap: balance sunt și ele larg disponibile (text-wrap: balance e cel mai nou, degradându-se elegant la ruperea normală acolo unde lipsește). Pentru un fallback pe browsere foarte vechi, un simplu font-size declarat înainte de linia cu clamp() e de ajuns.
Concluzii
- JavaScript-ul de potrivire a fontului pe care îl scrie toată lumea există mai ales fiindcă CSS nu putea dimensiona tipografia per container până când au apărut container queries.
container-type: inline-size+font-size: clamp(min, Ncqi, max)oferă tipografie relativă la container, cu un prag de mobil și un plafon de desktop.- Dacă dimensiunea pare ignorată, e cascada — depășește specificitatea selectoarelor largi din sistemul de design imbricându-ți regula.
cqiscalează după lățimea containerului, nu după lungimea șirului; foloseșteoverflow-wrap: break-wordșihyphens: autoca să rupi cuvintele lungi curat, în loc să dea pe dinafară.hyphens: autoține cont de limbă gratis cândlang-ul documentului e setat corect.- Recurge la JavaScript doar când chiar ai nevoie de o potrivire exactă, un rând per șir.
Acum e publicat — două pachete minuscule, fără dependențe: @oleksiimazurenko/react-fit-text pentru React și @oleksiimazurenko/fit-text pentru nucleul CSS agnostic de framework (codul sursă pe 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>