Skip to content

Liiift-Studio/TextBreath

Repository files navigation

Text Breath

npm License: MIT part of liiift type-tools

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


Install

npm install @liiift-studio/textbreath

Usage

Next.js App Router: this library uses browser APIs. Add "use client" to any component file that imports from it.

React component

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.

React hook

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.

Vanilla JS

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)

TypeScript

import type { BreatheOptions } from '@liiift-studio/textbreath'

const opts: BreatheOptions = { amplitude: 0.012, period: 3.5, mode: 'tide' }

Options

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 π/40.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)

How it works

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.


Dev notes

next in root devDependencies

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.


Future improvements

  • Multi-axis mode — animate both letter-spacing and 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-motion in vanilla JS — the React hook already pauses the animation when the user has opted out of motion; expose the same guard from startBreathe so vanilla JS callers don't need to implement the media query themselves

Current version: v1.0.13

Releases

No releases published

Packages

 
 
 

Contributors