Ритм

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


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

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, расстояние между его строками.

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

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

Скилл для агента

# skills.sh
npx skills add lotva/rhythm --skill rhythm

# GitHub CLI
gh skill install lotva/rhythm rhythm

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

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: [
		[
			/^(min-[wh]|max-[wh]|size|basis|[pmwh]|gap|inset|top|right|bottom|left|start|end)(?:-?([trblxyse]|bs|be))?-(\d*\.?\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',
					'min-w': 'min-width',
					'max-w': 'max-width',
					'min-h': 'min-height',
					'max-h': 'max-height',
					basis: 'flex-basis',
					gap: 'gap',
					inset: 'inset',
					top: 'top',
					right: 'right',
					bottom: 'bottom',
					left: 'left',
					start: 'inset-inline-start',
					end: 'inset-inline-end',
				}

				const base = basePropertyMap[property]

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

				if (property === 'size') {
					return directive ? undefined : { width: value, height: value }
				}

				if (directive && !['p', 'm', 'inset'].includes(property)) return

				if (
					property === 'inset' &&
					directive &&
					['t', 'r', 'b', 'l'].includes(directive)
				)
					return

				if (!base) return

				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]))
			},
		],
	],
})

Правило охватывает отступы (p, m, gap), размеры (w, h, min-w, max-w, min-h, max-h, size, basis) и физическое или логическое позиционирование (inset, top, right, bottom, left, start, end). Отрицательные значения обрабатывает стандартный вариант - в UnoCSS; самому правилу разбирать этот префикс не нужно.

// Или задайте определённый набор значений:

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 mbs-2gr gap-x-1g min-w-12g max-w-40g size-4g basis-12g start-1g -top-0.5gr"
></pre>