Rhythm

Spacing derived from typography.


Rhythm is a spacing system based on font metrics. Instead of fixed pixel modules like 8 px, it uses a paragraph’s visual line spacing—baseline-to-baseline distance minus x-height—to create a typographically consistent, responsive spacing scale.

TLDR

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

:root {
	/* The line height minus x-height */
	--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);
}

Since visual line spacing comes directly from font metrics, the system automatically adapts to the typeface and works across projects without additional configuration. Designers only need to choose an appropriate line height.

Advantages:

Simple mental model. The minimum spacing around an element equals var(--gap)—the distance between its text lines.

Responsive by design. Spacing scales with font size and line height, making fluid typography simpler to manage.

Typeface-aware. When the font changes, spacing adjusts automatically to match its metrics.

How to use

First, trim the text container to the visual bounds of the font:

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

Then define the base gap—the paragraph’s visual line spacing:

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

Define a relative version that scales with the element’s font size:

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

You can then build the spacing scale based on your project’s needs:

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

Why it works

Most design systems define a module—a base unit used to scale sizes across the interface. A module makes layout decisions predictable and easier to translate into code.

In many systems, this module is a fixed value, typically 8 px. It is applied to everything: font sizes, spacing, border radii, and component dimensions.

But layout spacing rarely behaves like a grid unit. In practice, designers tend to choose spacing relative to typography—specifically the line gap between text lines. For example:

  • the distance between an illustration and a paragraph should not be smaller than the line gap of the paragraph,
  • the space between a label and its heading should match the heading’s line gap.

Rhythm formalizes this pattern by using the line gap as the base spacing unit.

:root {
	/* In font metrics this equals: */
	/* ascent + descent + lineGap − xHeight */

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

Browser support

The approach has become practical only in recent years thanks to support for text-box, rlh, rex, and lh. Current browser support is about 81%.

To improve compatibility, you can generate a polyfill for text-box using the X-Size tool.

Use in 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})`
/* Usage */

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

Use in Tailwind

/* globals.tailwind.css */

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

	/* Or: */
	--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);
}

Use in UnoCSS

// uno.config.ts

// The recommended way is to create a custom rule:

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]))
			},
		],
	],
})
// Or use predefined values:

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)',
		},
	},
})
<!-- Usage -->

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