Skip to main content
Назад к блогу
CSSContainer QueriesTypographyFrontendPerformance

Вписываем текст в контейнер на чистом CSS — без JavaScript

Все пишут JavaScript-хук, который уменьшает font-size, пока текст не влезет в контейнер. Вот полностью рабочая альтернатива без единой строчки JS — на единицах container query, clamp() и переносе с дефисом по языку, включая ловушку каскада, из-за которой многие сдаются.

Опубликовано 24 июля 2026 г.11 мин чтения

У крупных display-заголовков есть досадная привычка: текст, идеально вписанный в макет, вылезает за границы, как только контейнер сужается или строка становится длиннее на другом языке. Годами стандартным решением был JavaScript — измерить отрисованный текст, сравнить с контейнером и уменьшать шрифт, пока не влезет. Почти каждый изобретает это заново: самодельный хук useFitText, цикл на ResizeObserver или библиотека вроде fitty, textFit или react-textfit.

Эта статья показывает полностью рабочую альтернативу с нулём JavaScript: шрифт, масштабирующийся под свой контейнер с помощью CSS-единиц container query, плюс аккуратный перенос строк с дефисом. Без хуков, без измерений, без сдвига вёрстки после гидратации.

JavaScript, который пишут все

Паттерн всегда один и тот же. Отрисовать текст в каком-то базовом размере, считать ширину текста и ширину его родителя, и если текст шире — уменьшить размер шрифта и попробовать снова. Минимальная версия выглядит так:

useFitText.ts
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 за раз» вызывает несколько повторных рендеров и заставляет делать синхронные чтения layout — именно ту работу, которой не хочется на главном потоке.

Почему 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.css
.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 для фреймворк-независимого CSS-ядра (исходный код на GitHub):

npm install @oleksiimazurenko/react-fit-text
import { 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>