Вписуй текст у контейнер чистим CSS — без JavaScript
Усі пишуть JavaScript-хук, який зменшує font-size, поки текст не влізе в контейнер. Ось повністю робоча альтернатива без жодного JS — на container query одиницях, clamp() і переносах слів з урахуванням правил мови, включно з пасткою каскаду, через яку багато хто здається.
Великі display-заголовки мають прикру звичку: текст, що ідеально вписується в дизайн, вилазить за межі, щойно контейнер вужчає або текст стає довшим іншою мовою. Роками стандартним рішенням був JavaScript — виміряти відрендерений текст, порівняти з контейнером і зменшувати шрифт, поки не влізе. Майже кожен винаходить це заново: саморобний useFitText-хук, цикл на ResizeObserver або бібліотека на кшталт fitty, textFit чи react-textfit.
Ця стаття показує повністю робочу альтернативу з нулем JavaScript: шрифт, що масштабується під свій контейнер завдяки CSS container query одиницям, плюс акуратний перенос рядків із дефісом. Без хуків, без вимірювань, без стрибка розкладки після гідрації.
JavaScript, який пише кожен
Патерн завжди однаковий. Відрендерити текст у якомусь базовому розмірі, зчитати ширину тексту й ширину його батька, і якщо текст ширший — зменшити розмір шрифту та спробувати знову. Мінімальна версія має такий вигляд:
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 };
}Воно працює, але має реальну ціну:
- Воно виконується після монтування компонента, тож текст спершу малюється неправильним розміром, а потім стрибає — видимий зсув розкладки й удар по CLS.
- У фреймворках із серверним рендерингом сервер віддає один розмір, а клієнт виправляє його після гідрації, що може спричинити мигання.
- Воно вантажить JavaScript заради того, що по суті є задачею розкладки.
- Цикл «по 1px за раз» спричиняє кілька повторних рендерів і змушує до синхронних читань розкладки — саме та робота, якої не хочеться на головному потоці.
Чому JavaScript був відповіддю — донедавна
Варто бути чесними, чому ці хуки взагалі існують. Поки container queries не з'явилися в браузерах, CSS просто не міг задати розмір шрифту відносно елемента. Можна було масштабувати відносно в'юпорта через vw, але заголовок у сайдбарі шириною 400px і той самий заголовок у hero шириною 1200px отримали б ідентичний розмір на основі vw, бо vw нічого не знає про контейнер.
Container query одиниці довжини це змінили. Вони стали безпечними крос-браузерно лише близько 2023 року, тож будь-який код, що вписував шрифт, написаний раніше, не мав CSS-варіанту і з необхідності вдавався до JavaScript. Цього обмеження вже немає — і саме в цьому суть статті.
Будівельний блок: container query одиниці
Container query одиниця довжини — це відсоток від розміру query-контейнера. Познач елемент як контейнер через container-type: inline-size, і його нащадки зможуть використовувати cqi — де 1cqi дорівнює 1% inline-розміру контейнера (його ширини за горизонтального напрямку письма).
.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;
}Тепер той самий заголовок у картці 400px має 40px, а в картці 1200px — 120px, автоматично, без вимірювань. Це і є той шматок, якого бракувало до container queries: розмір шрифту, що реагує на батька.
Обмежуємо розмір через clamp()
Голий cqi нескінченно ріс би на величезних контейнерах і стискався б до нуля на крихітних. Загорни його в clamp(), щоб задати нижню й верхню межі — зазвичай мобільний розмір як мінімум, а desktop-розмір як максимум:
.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 */
);
}Читай це так: будь плинним разом із контейнером, але ніколи не менше 2rem і не більше 4.5rem. Середній доданок (нахил cqi) керує тим, як швидко масштабується шрифт; дві межі — це мобільний і desktop розміри шрифту з твого дизайну. Якщо ці розміри лежать у кастомних властивостях, можна керувати всім через них:
.title {
--size-desktop: 72;
--size-mobile: 32;
}
.title .fit {
font-size: clamp(
calc(var(--size-mobile) * 1px),
10cqi,
calc(var(--size-desktop) * 1px)
);
}Пастка, що коштуватиме тобі годину: каскад
Ось пастка, через яку люди здаються і роблять висновок: "container queries не працюють". Контейнер резолвиться правильно, одиниця вірна, а шрифт усе одно не змінює розмір. Причина майже завжди — каскад, а не container queries.
Симптом підступно оманливий. Твій шрифт застряг на максимумі в кожному контейнері, хоч який вузький — а це точно те, як поводиться cqi, коли не знаходить контейнера і відкочується до в'юпорта. Тож ти йдеш перевіряти очевидне: чи правильно налаштований контейнер? Ти підтверджуєш, що container-type: inline-size на місці, проходиш DOM, щоб пересвідчитися, що предок справді є контейнером, зчитуєш обчислений container-type елемента, і він каже inline-size, вимірюєш ширину контейнера — і вона правильна. Усе сходиться. А шрифт усе одно застряг. Ти шукаєш не там.
Найшвидший вихід — перестати довіряти власному правилу й довести одиницю незалежно. Вкинь у той самий контейнер одноразовий пробник, що не містить нічого, крім голого значення cqi, і виміряй його:
// 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();Якщо пробник масштабується правильно, а твій справжній елемент лишається застряглим — це і є весь діагноз: container queries працюють бездоганно, а щось інше перебиває твій font-size. Це каскад — і винуватцем зазвичай є ширший селектор, про який ти забув.
Дизайн-системи часто задають типографіку на вкладених елементах правилом на кшталт .title p, .title span { font-size: … }. Цей селектор має специфічність (0,1,1). Якщо твоє fit-правило націлене на той самий елемент одним класом — .fit { … } зі специфічністю (0,1,0) — правило дизайн-системи виграє й фіксує розмір шрифту, тож твій clamp() мовчки перебивається. Обидва можуть навіть жити в одному каскадному шарі, тож шари тебе не врятують. Гірше того — те ширше правило, ймовірно, було viewport-based, тому розмір і виглядав як viewport-фолбек: ти буквально бачив, як виграє інше правило.
Виправлення — дати своєму правилу щонайменше рівну специфічність. Вкласти його під клас контейнера — найпростіший спосіб: .title .fit має (0,2,0), що перемагає .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); }Якщо здається, що розмір шрифту з container query ігнорується, проінспектуй елемент і перевір, яке правило насправді задає <code>font-size</code>. У дев'яти випадках із десяти ширший селектор дизайн-системи виграє каскад — а сама container query в порядку.
Проблема довгого слова — і чому це не провал container query
Є одна річ, якої cqi справді не вміє: він масштабує розмір шрифту за шириною контейнера, а не за довжиною конкретного рядка. Одне нерозривне слово все ще може бути ширшим за контейнер на обчисленому розмірі. Німецька — класичний винуватець: слово на кшталт Fremdsprachenkenntnisse залюбки вилізе за вузьку рамку навіть за відповідного контейнеру розміру шрифту.
Це не баг підходу; це його чесна межа. Старий JavaScript-хук розв'язував цей випадок, вимірюючи саме це слово й зменшуючи далі. У CSS ти робиш інакше — даєш слову перенестися, замість зменшувати весь заголовок. Дві властивості виконують роботу:
.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 — це запобіжна сітка: якщо слово не влазить у рядок, браузер розриває його, а не дає вилізти за межі. text-wrap: balance — це шліфування: воно вирівнює довжини рядків, щоб перенесений заголовок не завершувався самотнім одним словом.
Перенос із дефісом — автоматично, для кожної мови
Розривати слово посередині без дефіса виглядає грубо. CSS може вставити справжній дефіс у лінгвістично коректній точці через hyphens: auto. Нюанс, який варто зрозуміти: перенос залежить від мови. Браузер використовує словник переносів для мови елемента, яку зчитує з атрибута 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;
}Найелегантніше: якщо твій документ уже задає мову на корені — <html lang="de"> для німецької сторінки — перенос просто працює, для кожної локалі, без додаткового налаштування. Німецькі сторінки переносять німецьким словником, англійські — англійським. Ти отримуєш коректні дефіси кожною мовою безкоштовно, поки lang відповідає контенту.
Тримай поруч overflow-wrap: break-word. hyphens: auto опрацьовує слова, які вміє переносити; фолбек ловить усе інше (URL, назви брендів, вигадані складні слова), тож ніщо ніколи не вилазить.
Складаємо все докупи
Ось повний компонент без залежностей. Зверни увагу на структуру з двох елементів: зовнішній елемент, що є контейнером, і внутрішній, що зчитує з нього cqi. Елемент не може масштабувати власний шрифт від власного контейнера, тож потрібен поділ батько/дитина.
<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;
}Ось і вся фіча: заголовок, що масштабується разом із контейнером, тримається мобільної нижньої та desktop верхньої межі, ніколи не вилазить і переноситься з коректними дефісами будь-якою мовою — і не вантажить жодного байта JavaScript.
CSS проти JavaScript, чесно
| Аспект | JS fit-text хук / бібліотека | Чистий CSS (cqi + clamp) |
|---|---|---|
| Реагує на ширину контейнера | Так (через вимірювання) | Так (через cqi) |
| Реагує на точну довжину рядка | Так | Ні — натомість обмежено через overflow-wrap / hyphens |
| Зсув розкладки після завантаження | Часто (виміряти, потім масштабувати) | Немає — коректно з першого paint |
| Працює під час SSR до гідрації | Ні | Так |
| Відвантажений JavaScript | Так | Немає |
| Вартість головного потоку | Reflow + повторні рендери | Нуль |
| Перенос з урахуванням мови | Вручну | Вбудовано через hyphens: auto |
Єдина колонка, де JavaScript усе ще виграє — це точне вписування під конкретний рядок: змусити конкретний заголовок стати рівно в один рядок найбільшим розміром, що влазить. Якщо ця точна поведінка — жорстка вимога, вимірювання (JS або SVG <text>, що масштабується під viewBox) лишається єдиним шляхом. Але для переважної більшості display-заголовків контейнеро-залежне масштабування виглядає так само добре або краще — без усієї цієї ціни.
Підтримка браузерів
Container query одиниці довжини (cqi та споріднені) підтримуються всіма сучасними evergreen-браузерами, і так із 2023 року. clamp(), overflow-wrap, hyphens і text-wrap: balance теж широко доступні (text-wrap: balance найновіший, м'яко відкочується до звичайного переносу там, де його немає). Для фолбеку на дуже старих браузерах достатньо простого font-size, оголошеного перед рядком із clamp().
Висновки
- JavaScript для вписування шрифту, який пише кожен, існує здебільшого тому, що CSS не міг задати розмір шрифту за контейнером, поки не з'явилися container queries.
container-type: inline-size+font-size: clamp(min, Ncqi, max)дає контейнеро-залежний шрифт із мобільною нижньою й desktop верхньою межами.- Якщо розмір ніби ігнорується — це каскад; перевершуй специфічність широких селекторів дизайн-системи, вкладаючи своє правило.
cqiмасштабує за шириною контейнера, а не за довжиною рядка; використовуйoverflow-wrap: break-wordіhyphens: auto, щоб довгі слова акуратно переносились, а не вилазили.hyphens: autoзалежить від мови безкоштовно, колиlangдокумента заданий правильно.- Бери JavaScript лише тоді, коли тобі справді потрібне точне вписування, коли кожен рядок має лягти рівно в один рядок.
Тепер це опубліковано — два крихітні пакети без залежностей: @oleksiimazurenko/react-fit-text для React і @oleksiimazurenko/fit-text для framework-незалежного CSS-ядра (вихідний код на 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>