textbreath

The paragraph
breathes.

npm ↗
GitHub ↗
TypeScriptZero dependenciesReact + Vanilla JS

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

0.012
3.5
0.25π
AxisWaveMode— each line oscillates independently

Each line oscillates at ±0.012 em, period 3.5s, phase offset 0.25π per line.

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 animations

Options

OptionDefaultDescription
amplitude0.012Peak 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).
period3.5Seconds per full oscillation cycle.
phaseOffsetπ/4Phase 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.
pauseOffscreentrueSkip 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.
cancelOffscreenfalseCancel 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-motionstartBreathe 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)').