textbreath
The paragraph
breathes.
Each line of a paragraph oscillates its letter-spacing — or variable font axis — at a phase offset from its neighbours. Two modes: phase gives each line a fixed ripple; tide sends a traveling wave through the paragraph. At low amplitudes it reads as living rather than animated.
Live demo — watch the paragraph
How it works
Phase mode
Each visual line is assigned a fixed phase offset. The wave function is evaluated at each line’s phase every frame. Lines oscillate in place at staggered positions in the cycle — a standing ripple rather than a wave that moves.
Tide mode
A wave travels through the paragraph from top to bottom (or bottom to top). Each line’s phase advances with time and its position in the paragraph — the same wave that passes through floodText, but applied to letter-spacing or a variable font axis.
Usage
Drop-in component
import { BreatheText } from '@liiift-studio/textbreath'
<BreatheText amplitude={0.012} period={3.5} phaseOffset={0.785}>
Your paragraph text here...
</BreatheText>Hook
import { useBreathe } from '@liiift-studio/textbreath'
const ref = useBreathe({ amplitude: 0.012, period: 3.5, phaseOffset: 0.785 })
<p ref={ref}>{children}</p>Vanilla JS
import { applyBreathe, startBreathe, removeBreathe, getCleanHTML, BREATHE_CLASSES, sawtoothWave, triangleWave } from '@liiift-studio/textbreath'
const el = document.querySelector('p')
const original = getCleanHTML(el)
// applyBreathe accepts lineDetection and linePreservation only — animation options go to startBreathe
const { lineSpans } = applyBreathe(el, original)
const stop = startBreathe(lineSpans, { amplitude: 0.012, period: 3.5 })
// Later — stop animation and restore:
stop()
removeBreathe(el, original)
// BREATHE_CLASSES lets you target injected spans for custom CSS
// sawtoothWave / triangleWave are exported for building custom animationsOptions
| Option | Default | Description |
|---|---|---|
| amplitude | 0.012 | Peak change per cycle. Em for letter-spacing; multiplied by 100 for wdth (amplitude=1 → ±100 wdth units), multiplied by 400 for wght (amplitude=1 → ±400 wght units). |
| period | 3.5 | Seconds per full oscillation cycle. |
| phaseOffset | π/4 | Phase shift between adjacent lines in radians. Used in phase mode only. |
| waveShape | 'sine' | 'sine' | 'triangle' | 'sawtooth' |
| axis | 'letter-spacing' | 'letter-spacing' | 'wdth' | 'wght' |
| mode | 'phase' | 'phase' = standing ripple per line, 'tide' = wave travels through paragraph. |
| direction | 'down' | Tide travel direction. 'down' | 'up'. Used in tide mode only. |
| 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. |
| pauseOffscreen | true | Skip animation work when the element is not in the viewport. Uses IntersectionObserver. The rAF loop keeps running — the tick simply does nothing while offscreen. Resume is instant with no frame delay. |
| cancelOffscreen | false | Cancel the rAF loop entirely when the element leaves the viewport and restart it on re-entry. Saves more CPU and battery than the default flag-based pause — useful for pages with many textBreath instances or long animations running offscreen. Adds one frame (~16 ms) of delay on resume. Requires pauseOffscreen to be true. |
| linePreservation | 'none' | Line width strategy during animation. 'none' — lines expand and contract freely. 'clamp' — each line is constrained to its natural width via max-width and overflow: hidden, preventing container overflow at high amplitudes. |
| as | 'p' | HTML element to render. (BreatheText only) |
Accessibility & compatibility
prefers-reduced-motion — startBreathe skips the animation loop when the user has requested reduced motion. The DOM structure is still built but no values are applied.
(update: slow) — On e-ink and slow-refresh displays (Kindle, reMarkable, etc.) both applyBreathe and startBreathe return immediately without restructuring the DOM or starting the rAF loop. The element is restored to its original HTML. Detection uses matchMedia('(update: slow)').