textbreath

The paragraph
breathes.

npm ↗
GitHub
TypeScript·Zero dependencies·React + 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

Amplitude (em)0.012
Period (s)3.5
Phase offset0.25π
AxisWaveMode— each line oscillates independently

Hold still and watch the paragraph. Each line is breathing at its own pace — expanding and contracting its letter-spacing in a slow oscillation, offset from its neighbours by a fixed phase angle. The top lines and the bottom lines never breathe together. A wave moves through the paragraph rather than a pulse. At the default amplitude the movement is almost subliminal: you notice something alive before you notice what it is. Increase the amplitude to see the mechanics. The wave shape changes the character of the motion — sine is smooth, triangle is more mechanical. The period controls how fast each line completes its cycle.

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 } from '@liiift-studio/textbreath'

const el = document.querySelector('p')
const original = getCleanHTML(el)
const { lineSpans } = applyBreathe(el, original, { amplitude: 0.012, period: 3.5 })
const stop = startBreathe(lineSpans, { amplitude: 0.012, period: 3.5 })

// Later — stop animation and restore:
stop()
removeBreathe(el, original)

Options

OptionDefaultDescription
amplitude0.012Peak change per cycle. Em for letter-spacing; scaled by 100/400 for wdth/wght.
period3.5Seconds per full oscillation cycle.
phaseOffsetπ/4Phase shift between adjacent lines in radians. Used in phase mode only.
waveShape'sine''sine' | 'triangle'
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.