Ритм

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


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

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);

Мотивация

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

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

Но отступы на самом деле не подчиняются произвольному значению в 8px или 0.25rem. Они подчиняются интерлиньяжу — расстоянию между базовыми линиями строк текста.

Почему так

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

Например, отступ от бирки до заголовка должен быть не меньше просвета между строками заголовка; отступ от иллюстрации до абзаца должен быть не меньше просвета между строками абзаца. Так, при выборе отступа дизайнер чаще всего смотрит на интерлиньяж.

Вот почему модуль отступов в системе «Ритм» равен межстрочному просвету:

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

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

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

Использование подхода стало возможным в 2025 году благодаря поддержке свойств 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: [
		[
			/^(p|m|w|h|gap)(t|r|b|l|x|y|s|e|bs|be)?-([\d.]+)g(r)?$/,
			(match) => {
				const [, property, directive, n, relative] = match

				const gapProperty = relative ? '--gap--relative' : '--gap'
				const value = `calc(var(${gapProperty}) * ${n})`

				const baseMap = {
					p: 'padding',
					m: 'margin',
					w: 'width',
					h: 'height',
					gap: 'gap',
				} as const

				const base = baseMap[property as keyof typeof baseMap]
				if (!base) return

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