Две плавные шкалы вместо корня 10px

04 Ноя 2025

Выпустил typographics@3.0.0. Перед стабильным релизом обкатал новую мажорную версию в реальных проектах. Главное изменение оказалось не в новой текстовой роли и не в ещё одном CSS-свойстве. Убрал из библиотеки старое правило:

html {
  font-size: 10px;
}

Этот приём много лет жил в моих проектах ради простой арифметики: 1.6rem легко читать как 16px. Для собственного проекта это удобно. Для переиспользуемой библиотеки уже нет — typographics менял корневой размер документа и начинал спорить с CSS проектов.

В 3.0.0 решил отказаться от привязки расчётов к фиксированным 10px. Библиотека должна учитывать корневой размер шрифта, который задаёт приложение.

Без привязки к корню 10px

До этого плавная шкала была завязана на 10px сразу в нескольких местах. Даже границы 600px и 1440px приходилось переводить в rem через деление на 10:

--t-font-scale-min-width-rem:
  calc((var(--t-font-scale-min-width, 600) / 10) * 1rem);

html {
  font-size: 10px;
}

Удобная локальная договорённость незаметно стала частью контракта библиотеки. Если проект использовал обычный корневой размер браузера или задавал свой, typographics всё равно приходил со своими 10px.

В 3.0.0 вместо фиксированных 10px в слое reset используется font-size: 100%:

@layer reset {
  html {
    font-size: 100%;
    text-size-adjust: 100%;
    box-sizing: border-box;
  }
}

rem по-прежнему отсчитывается от размера шрифта корневого элемента. Для расчёта плавной шкалы больше не нужно считать, что 1rem равен 10px.

Поэтому пришлось пересобрать и саму модель размеров.

Одного clamp() уже мало

Раньше у пакета была одна база --t-font-size-clamp. Крупные роли росли относительно неё через em, а часть ролей основного текста оставалась фиксированной в rem. После отказа от корня 10px решил разделить шкалы: у основного текста и у заголовков теперь свои плавные базы.

Ниже — параметры двух шкал, без полной формулы clamp():

:root {
  --t-body-font-size-min-scale: 0.875;
  --t-body-font-size-max-scale: 1.125;
  --t-body-font-size-min:
    calc(var(--t-body-font-size-min-scale) * 1rem);
  --t-body-font-size-max:
    calc(var(--t-body-font-size-max-scale) * 1rem);
  --t-body-font-size-clamp: clamp(/* ... */);

  --t-heading-font-size-min-scale: 1;
  --t-heading-font-size-max-scale: 1.25;
  --t-heading-font-size-min:
    calc(var(--t-heading-font-size-min-scale) * 1rem);
  --t-heading-font-size-max:
    calc(var(--t-heading-font-size-max-scale) * 1rem);
  --t-heading-font-size-clamp: clamp(/* ... */);
}

Причина разделения практическая. Заголовкам обычно нужен больший диапазон изменения размера, чем абзацам. При общей базе изменение её границ влияет и на заголовки, и на основной текст.

При корневом размере шрифта 16px и линейном изменении между 600px и 1440px базы выглядят так:

ширина база текста база заголовков
600px 14px 16px
1020px 16px 18px
1440px 18px 20px

Сами роли теперь не хранят готовый размер. Они задают коэффициент:

--t-display-large: 5.7;
--t-headline-large: 3.2;
--t-title-medium: 2;

Миксин заголовка умножает коэффициент на базу заголовков:

font-size: calc(
  var(--t-heading-font-size-clamp) * #{$k}
);

Для абзацев и списков используется база основного текста:

font-size: calc(
  var(--t-body-font-size-clamp) * #{$k}
);

Например, h2 соответствует headline-large с коэффициентом 3.2. При ширине 600px это 16px × 3.2 = 51.2px, при 1440px — 20px × 3.2 = 64px. База основного текста за тот же диапазон меняется с 14px до 18px. В пикселях размер заголовка меняется заметно сильнее, хотя обе шкалы используют одни и те же границы.

Теперь это две независимые настройки. Если в конкретном проекте заголовки должны расти сильнее или слабее, для этого больше не нужно менять поведение основного текста.

Семантическая разметка без тяжёлых селекторов

Параллельно с новой шкалой пересобрал CSS-правила typographics. Пакет всё чаще используется как базовые стили документа, поэтому странно требовать класс для каждого обычного h2 или p. В 3.0.0 обычные HTML-элементы тоже получают стили текстовых ролей:

:where(h1, .display-large) {
  @include typography-heading(var(--t-display-large));
}

:where(h2, .headline-large) {
  @include typography-heading(var(--t-headline-large));
}

:where(p, .body-medium) {
  @include typography-paragraph(1em, 1.45, 400);
}

Можно написать обычную статью с h1, h2 и p и сразу получить базовую типографику. Классы ролей остаются доступны для случаев, когда семантика и визуальная роль не совпадают.

Вторая половина решения — :where(). У него нулевая специфичность, поэтому базовое правило для HTML-элемента легко переопределить в CSS проекта.

По той же причине весь CSS теперь разложен по слоям каскада:

@layer reset, tokens, typography, components, utilities;

Заполнены слои tokens, reset и typography. Обычные декларации CSS проекта вне слоёв имеют приоритет над обычными декларациями внутри слоёв. Получается нужное для библиотечного CSS поведение: typographics задаёт основу, но не пытается выиграть каскад у приложения любой ценой.

@layer и :where() решают разные задачи. Слои задают порядок групп правил в каскаде, а :where() не раздувает специфичность конкретных правил.

Обкатка перед релизом

Перед 3.0.0 обкатал 3.0.0-dev.0 и 3.0.0-dev.1 на реальных проектах.

В финальном варианте оставил старые границы 600px и 1440px, но сама система вокруг них теперь другая. Проект может переопределить корневой размер шрифта. Основной текст и заголовки масштабируются независимо. Семантические элементы получают базовые значения, которые легко переопределить в CSS проекта.

Библиотеке больше не нужен фиксированный корневой размер шрифта, чтобы удобно считать типографику.

Материалы