axisrhythm

Per-line axis
alternation.

npm ↗
GitHub ↗
TypeScriptNo required dependenciesReact + Vanilla JS

CSS applies font variation settings to the whole element. Axis Rhythm applies them line by line — cycling any axis through a sequence of values across paragraph lines. The result is a texture the eye reads as rhythm, not noise.

Live demo — drag the sliders

Axis High700
Axis Low300
Period2
Axis
Align
Preserve

Typography has always been as much about texture as legibility. The even grey of a well-set paragraph — called its colour by compositors — depends on consistency: consistent spacing, consistent weight, consistent rhythm from line to line.

Variable fonts crack this open. The wdth axis can compress or expand a letterform; the wght axis can lighten or darken it; the opsz axis can adjust optical weight for the point size. Applied uniformly, these give you a different typeface.

Applied line by line, they give you something more interesting: a paragraph with rhythm. Each line carries a different setting but the text reads as one. The difference is a texture the eye feels before the mind names it.

Each line gets a different axis value. The paragraph reads as one — like column highlighting for text. The alternation gives the eye a subtle landmark on every line, so it can track its position and find the start of the next without losing its place.

How it works

CSS stops at the element

font-variation-settings applies a single setting to an entire element. Every line gets the same axis value. There’s no way to target individual lines — they’re not DOM nodes.

Axis Rhythm works line by line

The algorithm detects visual lines using glyph positions, then wraps each in a span with its own font-variation-settings. Resize, reflow, inline elements — all handled automatically.

It aids reading

Alternating axis values create a subtle visual banding across the paragraph — like column highlighting in a spreadsheet, but for text. The eye uses the variation as a landmark: each line has a slightly different texture, so you always know which line you’re on and where the next one begins.

Line length preservation

The linePreservation option prevents reflow when the axis changes character widths. 'spacing' compensates with letter-spacing per line — exact widths, no glyph distortion. 'scale' uses a GPU scaleX transform — faster, minor horizontal compression at large ranges.

Usage

TypeScript + React · Vanilla JS

Drop-in component

import { AxisRhythmText } from '@liiift-studio/axisrhythm'

<AxisRhythmText axis="wdth" values={[100, 88]} period={2}>
  Your paragraph text here...
</AxisRhythmText>

Hook — attach to any element

import { useAxisRhythm } from '@liiift-studio/axisrhythm'

const ref = useAxisRhythm({ axis: 'wdth', values: [100, 88], period: 2 })
<p ref={ref}>{children}</p>

Vanilla JS — static

import { applyAxisRhythm, getCleanHTML } from '@liiift-studio/axisrhythm'

const el = document.querySelector('p')
const original = getCleanHTML(el)
applyAxisRhythm(el, original, { axis: 'wdth', values: [100, 88], period: 2 })

Vanilla JS — animated

import { startAxisRhythm, getCleanHTML } from '@liiift-studio/axisrhythm'

const el = document.querySelector('p')
const original = getCleanHTML(el)
const stop = startAxisRhythm(el, original, {
  axis: 'wght', values: [300, 700], period: 3,
  animate: true, waveShape: 'sine', speed: 0.5,
})
// Later: stop() cancels the animation

Options

AxisRhythm API options
OptionDefaultDescription
axis'wdth'Variable font axis tag, e.g. 'wdth', 'wght', 'opsz'.
values[100, 96]Axis values to cycle through across lines.
period2Lines per cycle.
align'top''top' counts from first line, 'bottom' from last. 'end' anchors to the reading direction’s trailing edge (equivalent to 'bottom' in LTR text).
source'fixed''fixed' cycles through values in order. 'syllable-density' maps per-line syllable density to the value range — complex lines get one end, simple lines the other. Requires the syllable package.
animatefalseTurn the static snapshot into a continuous ambient wave. Uses startAxisRhythm internally. Ignored by applyAxisRhythm.
waveShape'sine'Wave shape for animated mode. 'sine' — smooth oscillation. 'triangle' — linear transitions. 'spring' — sine with slight overshoot at peaks.
speed1Animation speed multiplier. At 1, one full cycle takes 4 s. Use values below 1 for imperceptible background motion.
syncToSynchronise phase with another element’s animation loop. The target element must already have startAxisRhythm running on it.
lineDetection'bcr''bcr' reads actual browser layout — ground truth, works with any font and inline HTML. 'canvas' uses @chenglou/pretext for arithmetic line breaking with no forced reflow on resize. Install pretext separately.
linePreservation'none''none' — no compensation. 'spacing' — adjusts letter-spacing per line to match natural line widths; prevents reflow. 'scale' — applies a CSS scaleX transform per line; GPU-composited, no letter-spacing change.
intersectfalsePause axis alternation when the element scrolls out of view; resume when visible. Uses IntersectionObserver internally.
as'p'HTML element to render, e.g. 'h1', 'div', 'li'. Accepts any valid React element type. (AxisRhythmText only)

Accessibility & compatibility

prefers-reduced-motion — when the user has enabled reduced motion in their OS settings, all axis alternation is skipped and the element is restored to its original HTML. No spans are injected; the text renders as plain prose.

update: slow — on e-ink and slow-refresh displays (Kindle, reMarkable, and similar panels), variable font axis animations produce no visible effect because the panel cannot refresh fast enough to show the transition. Axis Rhythm detects matchMedia('(update: slow)') and returns early, restoring the element to its original HTML without injecting any spans or applying any axis values.