per-line axis rhythm
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, switch the text, start the wave
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 measures where the browser broke the lines, then styles each line in place with its own font-variation-settings. Links and emphasis stay single elements, so a link that wraps is still one link. It re-runs on resize and when fonts load, and text without spaces (Chinese, Japanese) breaks between characters as usual.
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 — no spacing change, minor horizontal compression at large ranges, and it needs one box per line, so an element that crosses a line break is copied into each line.
Usage
TypeScript + React · Vanilla JS
Drop-in component
import { AxisRhythmText } from '@overpunch/axisrhythm'
<AxisRhythmText axis="wdth" values={[100, 88]} period={2} linePreservation="spacing">
Your paragraph text here...
</AxisRhythmText>Hook — attach to any element
import { useAxisRhythm } from '@overpunch/axisrhythm'
const ref = useAxisRhythm({ axis: 'wdth', values: [100, 88], period: 2 })
<p ref={ref}>{children}</p>Vanilla JS — static
import { applyAxisRhythm, getCleanHTML } from '@overpunch/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 '@overpunch/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 animationOptions
| Option | Default | Description |
|---|---|---|
| axis | 'wdth' | Variable font axis tag, e.g. 'wdth', 'wght', 'opsz'. |
| values | [100, 96] | Axis values to cycle through across lines. |
| period | 2 | Lines 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. |
| animate | false | Turn 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. |
| speed | 1 | Animation speed multiplier. At 1, one full cycle takes 4 s. Use values below 1 for imperceptible background motion. |
| syncTo | — | Synchronise 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. |
| intersect | false | Static: wait until the element enters the viewport before measuring lines. Animated: pause the wave while the element is off screen. 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, the animation is skipped and the static per-line texture stays, since it isn't motion. Turning the setting on while the wave is running stops it where the lines are.
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.
no-code
Use it in Webflow, Framer & Figma
The same effect, no build step — drop it straight into your design tool.
Webflow
One script tag, then mark any element with data-axisrhythm. Configure it with data-* attributes.
<!-- Site Settings → Custom Code → Footer, or an Embed element -->
<script src="https://cdn.jsdelivr.net/npm/@overpunch/axisrhythm/dist/axisrhythm.webflow.min.js"></script>
<!-- Then add data-axisrhythm to any text element -->
<h1 data-axisrhythm>Your headline</h1>Framer
Insert → Code → New Component, then paste AxisRhythm.tsx ↗. It imports the core from esm.sh and exposes every option in the property panel — no build step.
import { /* core */ } from "https://esm.sh/@overpunch/axisrhythm"Figma · beta
Part of the Type Tools Figma plugin ↗ — Plugins → Development → Import plugin from manifest, run Type Tools, and pick this tool. Here it works with compromises — tracking or named-instance swaps (Figma can't set variable axes).