Ритм

Система отступов, основанная на типографике.


За базовый шаг системы «Ритм» взят межстрочный просвет — визуальное расстояние между строками абзаца.

TLDR

* {
	text-box: trim-both ex alphabetic;
}

:root {
	/* Высота строки минус высота строчной буквы */
	--gap: calc(1rlh - 1rex);
	--gap--relative: calc(1lh - 1ex);
}

p {
	margin-block-start: calc(var(--gap) * 2);
}

small + h1 {
	margin-block-start: var(--gap--relative);
}

Межстрочный просвет высчитывается как расстояние между базовыми линиями минус высота строчной буквы. Это значение — часть шрифтовых метрик, поэтому «Ритм» автоматически адаптируется к шрифту и применим к большинству проектов без дополнительной настройки. Единственная задача дизайнера — подобрать интерлиньяж.

Преимущества:

Простая ментальная модель. Минимально допустимое расстояние от элемента — gap, расстояние между его строками.

Адаптивность. Отступы масштабируются вместе с кеглем и высотой строки. Это упрощает работу с флюидной типографикой.

Согласованность со шрифтом. При смене шрифта отступы пересчитываются согласно его метрикам.

Как внедрить в проект

1. Ограничьте высоту контейнера с текстом до высоты строчной буквы. Это нужно, чтобы правильно считать просвет от границ блочных HTML-элементов:

* {
	text-box: trim-both ex alphabetic;
}

2. Определите базовый отступ, равный межстрочному просвету:

:root {
	--gap: calc(1rlh - 1rex);
}

3. Определите тот же отступ в относительных единицах для использования с увеличенными кеглями:

:root {
	--gap--relative: calc(1lh - 1ex);
}

4. Задайте шкалу согласно потребностям проекта:

calc(var(--gap) * 0.5);
calc(var(--gap) * 0.75);
calc(var(--gap) * 1.5);
calc(var(--gap) * 2);
calc(var(--gap) * 3);
calc(var(--gap) * 4);

5. Чтобы предотвратить сдвиг раскладки (CLS), приведите метрики запасного шрифта к метрикам основного:

:root {
	font-size-adjust: ex-height 0.×××;
}

Мотивация

Модуль — базовая единица измерения, которой кратны размеры дизайн-системы. Модуль упрощает выбор размеров, делает решения объяснимыми и ускоряет перенос дизайна в код. Обычно он равен 8 пикселям.

В большинстве дизайн-систем модуль общий для всех измерений: для кегля, ширины и высоты, скруглений и отступов. На практике отступы — отдельная сущность, которая в наименьшей степени продиктована модулем и в большей степени подчиняется метрикам шрифта.

Метрики — это относительные дробные измерения, уникальные для каждого шрифта и лежащие в его основе. Они включают, например, высоту верхних (ascender) и нижних (descender) выносных элементов. Это значит, что к значениям отступов в CSS шрифт добавляет собственные расстояния, некратные восьмипиксельной сетке.

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

Вот почему отступы в «Ритме» имеют собственный модуль, вычисляемый относительно метрик:

:root {
	/* Межстрочный просвет выражен в метриках как */
	/* интерлиньяж минус высота строчной буквы: */
	/* ascent + descent + lineGap − xHeight */

	--gap: calc(1rlh - 1rex);
}

Межстрочный просвет выбран в качестве модуля неслучайно. Это привычный и удобный примитив, на который чаще всего опирается дизайнер при выборе отступов.

В ходе вёрстки мы пользуемся правилом, сформулированным в бюро Горбунова: «внутреннее ≤ внешнее». Внутренние расстояния типографического объекта не должны превышать внешние, иначе объекты вторгаются в личное пространство друг друга и слипаются.

Например:

  • отступ от бирки до заголовка должен быть не меньше просвета между строками заголовка;
  • отступ от иллюстрации до абзаца должен быть не меньше просвета между строками абзаца.

«Ритм» стандартизирует этот подход, взяв межстрочный просвет за базовый шаг системы. Это помогает решить типовые задачи в вёрстке и предотвратить ошибки, такие как влезание в интерлиньяж.

Браузерная поддержка

Подход опирается на возможности современного CSS: text-box, rlh, rex и lh. Браузерная поддержка — ≥ 81%.

Чтобы расширить совместимость, сгенерируйте полифилл для text-box с помощью инструмента «Икс-сайз».

Работа с PostCSS

// postcss.config.cjs

const { gap, gapRelative } = require('./src/functions')

module.exports = {
	plugins: [
		require('postcss-functions')({
			functions: {
				gap,
				gapRelative,
			},
		}),
	],
}
// functions.js

export const gap = (value = 1) =>
	`calc(var(--gap) * ${value})`

export const gapRelative = (value = 1) =>
	`calc(var(--gap--relative) * ${value})`
/* Использование: */

pre {
	padding: gap() gap(0.5) gap(2);
}

Работа с Tailwind

/* globals.tailwind.css */

@theme {
	--spacing: var(--gap);

	/* Или: */
	--spacing-0.5g: calc(var(--gap) * 0.5);
	--spacing-0.75g: calc(var(--gap) * 0.75);
	--spacing-1g: var(--gap);
	--spacing-1.5g: calc(var(--gap) * 1.5);
	--spacing-2g: calc(var(--gap) * 2);
	--spacing-3g: calc(var(--gap) * 3);
	--spacing-4g: calc(var(--gap) * 4);
	--spacing-1gr: var(--gap--relative);
}

Работа с UnoCSS

// uno.config.ts

// Скопируйте кастомное правило:

export default defineConfig({
	rules: [
		[
			/^([pmwh]|gap|inset|top|right|bottom|left)(?:-?([trblxyse]|bs|be))?-([\d.]+)g(r)?$/,
			(match) => {
				const [, property, directive, multiplier, isRelative] = match

				const gapVariableName = isRelative ? '--gap--relative' : '--gap'
				const value = `calc(var(${gapVariableName}) * ${multiplier})`

				const basePropertyMap: Record<string, string> = {
					p: 'padding',
					m: 'margin',
					w: 'width',
					h: 'height',
					gap: 'gap',
					inset: 'inset',
					top: 'top',
					right: 'right',
					bottom: 'bottom',
					left: 'left',
				}

				const base = basePropertyMap[property]
				if (!base) return

				if (property === 'gap') {
					if (directive === 'x') return { 'column-gap': value }
					if (directive === 'y') return { 'row-gap': value }
					if (!directive) return { gap: value }
					return
				}

				const coordinates = ['top', 'right', 'bottom', 'left']
				if (coordinates.includes(property)) {
					return { [base]: value }
				}

				const directiveMap: Record<string, string[]> = {
					'': [base],
					t: [`${base}-top`],
					r: [`${base}-right`],
					b: [`${base}-bottom`],
					l: [`${base}-left`],
					s: [`${base}-inline-start`],
					e: [`${base}-inline-end`],
					bs: [`${base}-block-start`],
					be: [`${base}-block-end`],
					x: [`${base}-inline-start`, `${base}-inline-end`],
					y: [`${base}-block-start`, `${base}-block-end`],
				}

				const properties = directiveMap[directive ?? '']
				if (!properties) return

				return Object.fromEntries(properties.map((p) => [p, value]))
			},
		],
	],
})
// Или задайте определённый набор значений:

export default defineConfig({
	theme: {
		spacing: {
			'0.5g': 'calc(var(--gap) * 0.5)',
			'0.75g': 'calc(var(--gap) * 0.75)',
			'1g': 'var(--gap)',
			'1.5g': 'calc(var(--gap) * 1.5)',
			'2g': 'calc(var(--gap) * 2)',
			'3g': 'calc(var(--gap) * 3)',
			'4g': 'calc(var(--gap) * 4)',
			'1gr': 'var(--gap--relative)',
		},
	},
})
<!-- Использование: -->

<pre class="p-1g -m-0.5g mt-0.5g mbs-2gr gap-1g"></pre>