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

Материалы