Вертикальный ритм на lh в typographics
20 Сен 2025
Выпустил typographics@2.4.7. За последние две недели пакет заметно разросся вокруг довольно простой проблемы: на реальной странице одной шкалы заголовков и классов основного текста оказалось мало. В тексте появляются списки, встроенный code, большие блоки кода, формы. Все они должны не просто иметь подходящий размер шрифта, но и нормально складываться друг с другом по вертикали.
До этого в основном приводил в порядок сам пакет. В 2.0.0 заменил Parcel на Vite, ограничил публикацию готовой папкой dist и привёл CSS-переменные к префиксу --t-*. Следующая задача пришла уже из использования typographics на реальных страницах: в моём блоге, у друзей которые использовали пакет, и в рабочих проектах.
Когда одной шкалы уже мало
В 2.2.0 я начал с маленького правила для абзацев. Последнему абзацу в группе обычно не нужен нижний отступ, поэтому в paragraph mixin появилось:
&:last-of-type {
margin-block-end: 0;
}
К 2.3.0 стало понятно, что этим документ не закрывается. Добавил размеры и интервалы для списков, стили для встроенного code и pre, наследование шрифта для input, button, select и textarea. Заодно появился общий базовый шаг:
:root {
--t-baseline: 0.8rem;
--t-half-baseline: calc(var(--t-baseline) / 2);
}
@mixin typography-paragraph($paragraph-font-size, $paragraph-line-height) {
font-size: $paragraph-font-size;
line-height: $paragraph-line-height;
margin-bottom: var(--t-half-baseline);
}
Так уже можно собирать обычную статью из одних и тех же примитивов, не настраивая каждый список и блок кода отдельно. Но общий шаг оставался фиксированной длиной. Для разных текстовых ролей с разным font-size и line-height хотелось, чтобы интервалы следовали самой строке текста.
Отступ как доля строки
В 2.4.0 я перевёл основные вертикальные интервалы на единицу lh. Она равна вычисленному line-height текущего элемента. Поэтому 0.5lh — это половина его строки, а 0.75lh — три четверти.
Для заголовков получилось так:
@mixin typography-heading($heading-font-size) {
font-size: $heading-font-size;
line-height: var(--t-line-height-heading, 1.3);
margin-top: 1.25lh;
margin-bottom: 0.5lh;
max-inline-size: 50ch;
text-wrap: balance;
}
Для основного текста:
@mixin typography-paragraph(
$paragraph-font-size,
$paragraph-line-height,
$paragraph-font-weight
) {
font-size: $paragraph-font-size;
line-height: $paragraph-line-height;
font-weight: $paragraph-font-weight;
margin-bottom: 0.75lh;
}
Одновременно сделал line-height текстовых ролей безразмерным. Например:
.body {
&-large { @include typography-paragraph(1.6rem, 1.5, 400); }
&-medium { @include typography-paragraph(1.4rem, 1.45, 400); }
&-small { @include typography-paragraph(1.2rem, 1.35, 400); }
}
При корневых 10px у .body-large размер шрифта равен 16px, а line-height: 1.5 даёт строку 24px. Значит, margin-bottom: 0.75lh превращается в 18px.
У .body-small шрифт уже 12px, line-height: 1.35 даёт 16.2px, а тот же 0.75lh — 12.15px. Правило одно, но интервал естественно меняется вместе с текстовой ролью.
То же самое работает для списков:
@mixin typography-list($font-size, $line-height, $margin-block) {
font-size: $font-size;
line-height: $line-height;
padding-inline-start: 2.5rem;
margin-block: $margin-block;
li { margin-block: 0.25lh; }
}
Задача не в “идеальной” базовой сетке на весь документ. Хотел связать расстояние между элементами с их фактическим межстрочным интервалом и за счёт этого получить более цельный вертикальный ритм при разных размерах текста.
last-of-type оказался слишком буквальным
После унификации миксинов быстро нашёлся край в правиле для последнего элемента. В 2.4.2 я применил его и к headings, и к paragraphs:
&:last-of-type {
margin-block-end: 0;
}
Проблема в том, что единственный элемент своего типа одновременно является и последним. Если в небольшом блоке находится только один абзац, селектор тоже срабатывает и убирает отступ, хотя следующий внешний элемент всё ещё может в нём нуждаться.
В 2.4.3 вынес reset в отдельный mixin и исключил этот случай:
@mixin reset-last-margin() {
&:last-of-type:not(:only-of-type) {
margin-block-end: 0;
}
}
Получилось чуть длиннее, зато правило теперь описывает нужный случай точнее: убрать хвост у последнего элемента своего типа, только если элементов этого типа в контейнере больше одного.
Отступ внутри горизонтальной прокрутки
Следующая мелочь проявилась у длинных блоков кода. Изначально pre сам отвечал и за overflow-x: auto, и за внутренний отступ:
pre {
padding: var(--t-code-block-padding, 1.2rem 2rem);
overflow-x: auto;
white-space: pre;
}
При горизонтальной прокрутке справа после последнего символа нужен такой же внутренний воздух, как слева. Поэтому в 2.4.4 отступ переехал на вложенный code, то есть внутрь прокручиваемого содержимого:
pre {
overflow-x: auto;
code {
padding: var(--t-code-block-padding, 1.2rem 2rem);
}
}
Этого оказалось недостаточно. В следующем патче code пришлось сделать inline-block, чтобы отступ участвовал в ширине внутреннего блока целиком:
pre {
overflow-x: auto;
code {
display: inline-block;
padding: var(--t-code-block-padding, 1.2rem 2rem);
}
}
Balance оставил только заголовкам
Ещё один эффект дал text-wrap: balance. Изначально он стоял и в миксине заголовков, и в миксине абзацев. На обычных абзацах при проверке это поведение оказалось лишним и тяжёлым, поэтому balance оставил только для заголовков.
@mixin typography-heading($heading-font-size) {
// ...
text-wrap: balance;
}
typographics всё ещё содержит fluid-шкалу из первой версии, но теперь поверх неё есть минимальный набор правил для реального документа: текстовые роли, списки, код и вертикальные интервалы, привязанные к line-height самих элементов.
Переход на lh получился небольшим по синтаксису. Основная работа оказалась вокруг него: проверить, что универсальные правила действительно остаются универсальными на одиночных элементах, длинном прокручиваемом коде и обычных абзацах.