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

04 Ноя 2025

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

html {
  font-size: 10px;
}

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

С 3.0 решил больше не считать корневой размер частью внутренней математики. Библиотека должна работать поверх размера документа, который задаёт приложение.

Корневой размер больше не часть формулы

До этого плавная шкала была завязана на 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 корневой размер снова обычный:

@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 решил сделать зависимость явной: у основного текста и у заголовков теперь свои плавные базы.

: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(/* ... */);
}

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

При стандартном корневом размере 16px текущие значения дают такую базу:

ширина база текста база заголовков
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. У заголовка получается заметно большая абсолютная амплитуда, хотя обе шкалы используют одни и те же границы.

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

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

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

: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(). У него нулевая специфичность, поэтому семантическое правило по умолчанию не превращается в селектор, который потом приходится перебивать всё более длинной конструкцией.

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

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

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

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

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

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

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

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

Материалы