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 at a staggered point in the cycle; tide sends a traveling wave through the paragraph from top to bottom. At low amplitudes it reads as living rather than animated.
textbreath.com · npm · GitHub
TypeScript · Zero dependencies · React + Vanilla JS
npm install @liiift-studio/textbreathNext.js App Router: this library uses browser APIs. Add
"use client"to any component file that imports from it.
import { BreatheText } from '@liiift-studio/textbreath'
<BreatheText amplitude={0.012} period={3.5} phaseOffset={0.785} linePreservation="clamp">
Your paragraph text here...
</BreatheText>linePreservation="clamp" constrains each line to its natural width so the breathing effect stays within the line box. Omit it if very small overflow at the line edge is acceptable.
import { useBreathe } from '@liiift-studio/textbreath'
// Inside a React component:
const ref = useBreathe({ amplitude: 0.012, period: 3.5, phaseOffset: 0.785 })
return <p ref={ref}>{children}</p>The hook starts the animation loop on mount, re-runs line detection on resize via ResizeObserver, and re-runs after fonts load via document.fonts.ready. Cleans up on unmount.
applyBreathe wraps lines and returns them. startBreathe drives the animation loop and returns a stop function.
import { applyBreathe, startBreathe, removeBreathe, getCleanHTML } from '@liiift-studio/textbreath'
const el = document.querySelector('p')
const original = getCleanHTML(el)
const opts = { amplitude: 0.012, period: 3.5 }
let { lineSpans } = applyBreathe(el, original, opts)
let stop = startBreathe(lineSpans, opts)
document.fonts.ready.then(() => {
stop()
lineSpans = applyBreathe(el, original, opts).lineSpans
stop = startBreathe(lineSpans, opts)
})
// On resize — stop, re-detect lines, restart:
const ro = new ResizeObserver(() => {
stop()
const { lineSpans: newSpans } = applyBreathe(el, original, opts)
stop = startBreathe(newSpans, opts)
})
ro.observe(el)
// Later — stop the animation loop and restore the DOM:
stop()
ro.disconnect()
removeBreathe(el, original)import type { BreatheOptions } from '@liiift-studio/textbreath'
const opts: BreatheOptions = { amplitude: 0.012, period: 3.5, mode: 'tide' }| Option | Default | Description |
|---|---|---|
amplitude |
0.012 |
Peak change per cycle. Em units for letter-spacing. For wdth, the axis value becomes 100 ± (amplitude × 100). For wght, it becomes 400 ± (amplitude × 400) |
period |
3.5 |
Seconds per full oscillation cycle |
phaseOffset |
π/4 ≈ 0.785 |
Radians of phase shift between adjacent lines. Used in 'phase' mode only |
waveShape |
'sine' |
'sine' | 'triangle' | 'sawtooth' |
pauseOffscreen |
true |
Pause the rAF loop via IntersectionObserver when the element is fully scrolled offscreen. Set to false to keep animating at all times |
axis |
'letter-spacing' |
Property to animate: 'letter-spacing' | 'wdth' | 'wght' |
mode |
'phase' |
'phase' — standing ripple, each line at a fixed phase offset. 'tide' — wave travels through the 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 (npm install @chenglou/pretext). Falls back to 'bcr' while pretext loads |
linePreservation |
'none' |
'none' — lines breathe freely in width (may overflow container at large amplitudes). 'clamp' — each line is constrained to its natural width via max-width and overflow: hidden; the breathing effect is contained within the line box with no container overflow. Characters at the trailing edge clip slightly during the wide phase |
as |
'p' |
HTML element to render. (React component only) |
Each visual line is wrapped in a <span>. In phase mode, line i is assigned a fixed phase of i × phaseOffset radians, and the wave is evaluated at that phase each frame. In tide mode, each line's phase advances with both time and its index — the same traveling wave used by Flood Text, but applied to letter-spacing or a variable font axis rather than per-character. Both modes run a requestAnimationFrame loop at consistent speed regardless of display refresh rate. In React, the loop stops automatically on unmount and is skipped entirely if prefers-reduced-motion: reduce is set. In vanilla JS, call the stop function returned by startBreathe to end the loop.
Line break safety: Line breaks are locked to the browser's natural layout — each applyBreathe call starts from the original HTML, detects lines at natural spacing, then locks them with white-space: nowrap. Word breaks never change during the animation.
Width overflow: Letter-spacing animation causes lines to grow and shrink with the wave. At the default amplitude: 0.012em the peak overflow for a 60-character line at 16px is approximately 11px — typically imperceptible. At larger amplitudes, use linePreservation: 'clamp' to contain the effect within each line box, or add overflow-x: hidden to the element's CSS.
package.json at the repo root lists next as a devDependency. This is a Vercel detection workaround — not a real dependency of the npm package. Vercel's build system inspects the root package.json to detect the framework; without next present it falls back to a static build and skips the Next.js pipeline, breaking the /site subdirectory deploy.
The package itself has zero runtime dependencies. Do not remove this entry.
- Multi-axis mode — animate both
letter-spacingand a variable font axis simultaneously from a single instance - Scroll-phase mode — tie the wave phase to scroll position rather than time, so the paragraph breathes as the user reads down the page
- Amplitude envelope — fade amplitude in on mount and out on unmount for a softer entrance and exit
prefers-reduced-motionin vanilla JS — the React hook already pauses the animation when the user has opted out of motion; expose the same guard fromstartBreatheso vanilla JS callers don't need to implement the media query themselves
Current version: v1.0.13