
Дизайнеры выравнивают текст по визуальной границе букв. Разработчики — по текстовой площадке ЦСС, которая где-то в полтора раза выше. Я скрафтил инструмент, который убирает лишнее пространство сверху и снизу текста.
В макетах «Индизайна» и «Скетча» отступы считаются от границ букв: снизу — от базовой линии, сверху — от высоты строчных либо заглавных. Другими словами, высота текстового блока равна высоте его букв:
На макете
— Верстаем!
В ЦСС высота текстовых элементов по умолчанию равна высоте строки. Это примерно в полтора раза больше высоты букв:
В жизни
— Верстаем!
При переносе из макета в браузер отступы становятся больше, хотя их числовое значение не меняется:
Желаемое
Дизайнер говорит, что отступ равен 38,4 пк, и фронтендер, что 38,4 пк.
Оба правы, но вместе пока не живут.
Действительное
Дизайнер говорит, что отступ равен 38,4 пк, и фронтендер, что 38,4 пк.
Оба правы, но вместе пока не живут.
В обоих случаях задан вертикальный отступ в 38,4 пк. Текстовый блок по умолчанию содержит расстояние, зарезервированное под выносные элементы в метриках шрифта. В сумме с высотой букв это расстояние образует высоту строки.
Чтобы добиться выравнивания по высоте букв, к тексту нужно применить компенсацию — накинуть отрицательный марджин.
UPD В 133-м Хроме для решения этой задачи появилось ЦСС-свойство text-box-trim
. Поддержка пока меньше пяти процентов, но всё равно балдёж:
* {
/* Обрезает до прописных */
text-box: trim-both cap alphabetic;
/* До строчных */
text-box: trim-both ex alphabetic;
}
Решение: бюро Горбунова
Разработчики бюро Горбунова придумали завернуть каждый текстовый элемент в класс-обёртку. Обёртка декларирует желаемый отступ, а ХТМЛ-элемент внутри обёртки с помощью отрицательных отступов убирает лишнее расстояние:
<div class="textNode">
<p>Сновидение понимает ролевой филогенез.</p>
</div>
<style>
.textNode {
margin-bottom: 36px;
}
p {
font: 'Bureauserif';
margin-top: -17px;
margin-bottom: -19px;
}
</style>
Минус подхода в том, что отступы нужно описать вручную для каждой пары кегля и интерлиньяжа:
.heading-2 {
font: 400 36px/36px Bureausans;
margin-bottom: -8px;
margin-top: -10px;
}
.caption {
font: 100 16px/18px Bureausans;
margin-bottom: -5px;
margin-top: -4px;
}
/* ... */
Хорошо, если мы знаем метрики шрифта. В противном случае разработчик вынужден мучать девтулзы и подбирать отступы на глаз. Это приведёт к полупиксельным зазорам.
Решение: Capsize
Разработчики компании Seek сделали инструмент, который сканирует метрики шрифта через FontKit и компенсирует отступ в относительных единицах:
Так как отступы округляются до одной десятитысячной «ема», «Капсайз» решает проблему зазоров. Плюс теперь не нужна обёртка .textNode
— стили назначаются на псевдоэлементы. Но разработчик по-прежнему подбирает стили для каждой пары интерлиньяжа и кегля, пусть теперь и в визуальном редакторе.
Я хочу проще. Давайте запишем метрики в ЦСС единожды, а кегль и интерлиньяж подставим через формулу.
Решение: моё
Чтобы сделать компенсацию отступа относительной:
- Вычтем из площадки значение, зарезервированное под выносные элементы шрифта при единичной высоте строки.
- Вычтем сверху и снизу из текстовой площадки половину от добавочной высоты строки:
.metrics-fix {
&::before {
content: '';
display: table;
margin-block-end: calc(-0.35em - (1lh - 1em) / 2);
}
&::after {
content: '';
display: table;
margin-block-start: calc(-0.15em - (1lh - 1em) / 2);
}
}
Про зарезервированное место. Здесь значения 0.35em
и 0.15em
автоматически рассчитаны из метрик. Они не зависят от высоты строки. 0.35em
— расстояние от заглавной буквы до верхней части площадки; 0.15em
— от базовой линии до нижней части площадки.
Про добавочную высоту строки. И сверху, и снизу мы вычитаем 1lh
минус 1em
, делённое на два. Почему так? Как только мы увеличиваем высоту строки, сверху и снизу к площадке добавляется расстояние, которое мешает обрезать строку до высоты букв. Это расстояние измеряется в «емах»: line-height: 1.3
— то же, что line-height: 1.3em
. Вычитая из высоты строки один «ем», мы получаем добавочное расстояние между строками; в этом случае — 0.3em
. Псевдоэлемент вычитает добавочное расстояние сверху и снизу:

Автоматизация
На этом сайте вертикальные отступы рассчитаны вплоть до субпикселя. Всё благодаря «Икс-сайзу» — виджету, который автоматически составляет псевдоэлементы с компенсацией.
«Икс-сайз» извлекает из метрик шрифта расстояния, зарезервированные под верхние и нижние выносные элементы, складывает их и получает общую высоту строки.
(UPD: после выпуска этого текста в вакансиях указывают субпиксель-пёрфект вёрстку как требование к соискателю.)
Виджет лежит на отдельной странице. Опробуйте в бою:
Икс-сайз — простая CSS-only альтернатива Capsize
Внедрение в проект
Загрузите используемые на сайте шрифты в виджет. Скопируйте код и примените стиль ко всем базовым текстовым элементам, которые используются на сайте, — спискам, абзацам, заголовкам. У меня так:
/* globals/font-metrics.css */
/* Typeface: PT Root UI */
.metrics-fix,
figcaption,
h1,
h2,
h3,
p,
dt,
dd {
&::before {
content: '';
display: table;
margin-block-end: calc(-0.35em - (1lh - 1em) / 2);
}
&::after {
content: '';
display: table;
margin-block-start: calc(-0.15em - (1lh - 1em) / 2);
}
}
/* Typeface: Martian Mono */
pre {
&::before {
content: '';
display: table;
margin-block-end: calc(-0.312em - (1lh - 1em) / 2);
}
&::after {
content: '';
display: table;
margin-block-start: calc(-0.088em - (1lh - 1em) / 2);
}
}
Класс .metrics-fix
применяет компенсацию к любому блоку.
💡 Иногда стили по умолчанию мешают сверстать кастомный элемент. Например, если вы делаете список карточек с display: grid
, псевдоэлементы станут элементами грида. Отключите их через ul:not(.metrics-fix--disabled)
.
Расхождения в метриках
Браузеры и операционные системы рендерят шрифты по-разному. Браузеры на Маке используют поля ascender
и descender
таблицы hhea
, на Виндоусе — usWinAscent
и usWinDescent
таблицы head
. Бывают исключения. Например, Файрфокс всегда берёт метрики из hhea
.
Если в метриках есть расхождения, интерлиньяж будет плавать. Приведите метрики к общим значениям.
Макс Колер написал, на какие поля обратить внимание. Всё ок, если:
sTypoAscender
иhheaAscender
равны. То же дляsTypoDescender
иhheaDescender
,sTypoLinegap
иhheaLinegap
;usWinAscent
равен наибольшемуymax
в таблице глифов;usWinDescent
равен наименьшемуymin
в таблице глифов, умноженному на -1.
Чтобы автоматизировать проверку, запустите скрипт, который приравняет все асцендеры к ymax
и все десцендеры к ymin
. В заметке есть способ через FontSquirrel, вот ещё два от меня:
- Зайдите на Transfonter и сконвертируйте шрифт со включенным чекбоксом Fix vertical metrics.
- Приравняйте Win Ascent и Win Descent к значениям таблицы Hhead в редакторе FontForge.
Полифил для lh
Единица измерения lh
поддерживается в браузерах с 2023 года. Сегодня 24 ноября 2024-го, поддержка — 91,26%. Если вам нужны древние браузеры, lh
заменяется кастомным свойством:
.metrics-fix {
&::before {
margin-block-end: calc(-0.35em - (1em * var(--lh) - 1em) / 2);
}
&::after {
margin-block-start: calc(-0.15em - (1em * var(--lh) - 1em) / 2);
}
}
Не советую использовать --lh
. Свойство придётся переопределять всякий раз, когда вы задаёте line-height
:
.some-component {
--lh: 1.3;
line-height: var(--lh);
}
Впрочем, это всё ещё проще, чем прописывать отступы вручную.
Дизайн через межстрочное расстояние
На личном сайте я задал кастомное свойство --gap
, которое равно межстрочному расстоянию (line gap). Это высота строки минус высота строчных.
--rex: calc(1rem / 2);
--rlh: calc(1rem * var(--typography__leading));
--gap: calc(var(--rlh) - var(--rex));
--gap--relative: calc(1lh - 1ex);
Отступ, равный интерлиньяжу, помогает соблюдать правило внутреннего и внешнего. Например, один абзацный отступ может занимать два «гэпа»: calc(var(--gap) * 2)
, то есть, два интерлиньяжа.
Бонус: как устроен алгоритм
Браузер рендерит строку так, что значение высоты строки по умолчанию — line-height: initial
— равно сумме высоты верхних выносных элементов (ascender), расстояния между строками (line gap) и нижних выносных элементов (descender).
Алгоритм считает, сколько в «емах» нужно убрать сверху и снизу. Снизу срезает от десцендера до базовой линии, сверху — от асцендера до прописной или до строчной в зависимости от дизайна:

Вот код на Яваскрипте:
// Считываем метрики
unitsPerEm = 1000
ascent = 1040
descent = -216
lineGap = 0
capHeight = 800
xHeight = 600
/*
* Область, в которой может находиться глиф.
* Она же — `line-height: initial;`
*/
const contentArea = Math.abs(descent) + lineGap + ascent
// Метрики в «емах»
const contentAreaScale = contentArea / unitsPerEm
const ascentScale = ascent / unitsPerEm
const descentScale = descent / unitsPerEm
const lineGapScale = lineGap / unitsPerEm
const capHeightScale = capHeight / unitsPerEm
const xHeightScale = xHeight / unitsPerEm
// Высота нижнего выносного элемента минус половина высоты строки
const descentTrim = (Math.abs(descentScale) + lineGapScale / 2 - (contentAreaScale - 1) / 2) * -1
/*
* Высота верхнего выносного элемента (можно заменить на xHeightScale)
* минус высота прописной буквы минус половина высоты строки
*/
const ascentTrim = (Math.abs(ascentScale - capHeightScale + lineGapScale / 2) - (contentAreaScale - 1) / 2) * -1
console.log(
parseFloat(descentTrim.toFixed(3)),
parseFloat(ascentTrim.toFixed(3))
)
Чтобы углубиться в тему
Deep dive CSS: font metrics, line-height and vertical-align. Статья Винсента Де Оливьеры о том, как браузер использует метрики шрифта при рендере и как это соотносится с высотой строки.
The Thing With Leading in CSS. Матиас Отт рассказывает о прениях между разработчиками и дизайнерами по поводу высоты строки.
What length CSS unit should you use? Схема-опрос, чтобы выбрать подходящую единицу измерения в зависимости от того, что вы верстаете.
LibFont. Я построил свой инструмент на базе LibFont. Эта либа добавляет объект Font()
, похожий на новый Image()
. Позволяет сканировать метаданные вроде x-height
и hhea.ascender
.
precomputeValues.ts
. Алгоритм нахождения компенсации отступов в исходном коде Capsize.