npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@liiift-studio/textbreath

v1.0.12

Published

Continuous subtle per-line letter-spacing oscillation at offset phases — the paragraph breathes

Readme

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' | | 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

  • Additional wave shapessawtooth for a sharp-edged, one-directional sweep per line
  • 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.11