@liiift-studio/floodtext
v1.0.4
Published
Per-character variable font wave animation — weight, width, oblique, or opacity washes through the paragraph letter by letter
Readme
Flood Text
A wave washes through the paragraph character by character — modulating weight, width, oblique angle, or opacity as it passes. Not line by line, not word by word: every letterform sits at its own moment in the curve. At low amplitude it reads as texture; at high amplitude, as transformation.
floodtext.com · npm · GitHub
TypeScript · Zero dependencies · React + Vanilla JS
Install
npm install @liiift-studio/floodtextUsage
Next.js App Router: this library uses browser APIs. Add
"use client"to any component file that imports from it.
React component
import { FloodText } from '@liiift-studio/floodtext'
<FloodText effect="wght" amplitude={200} period={4} density={2} direction="diagonal-down">
Your paragraph text here...
</FloodText>Layer multiple effects simultaneously:
<FloodText effect={['wght', 'oblique']} period={4} density={2}>
Your paragraph text here...
</FloodText>React hook
import { useFloodText } from '@liiift-studio/floodtext'
// Inside a React component:
const ref = useFloodText({ effect: 'wght', amplitude: 200, period: 4, density: 2 })
return <p ref={ref}>{children}</p>The hook starts the animation loop on mount, re-wraps characters and restarts on container resize via ResizeObserver, and cleans up on unmount.
Vanilla JS
applyFloodText wraps characters and returns them. startFloodText drives the animation loop and returns a stop function. Note: options are not used by applyFloodText — only startFloodText reads them.
import { applyFloodText, startFloodText, removeFloodText, getCleanHTML } from '@liiift-studio/floodtext'
const el = document.querySelector('p')
const original = getCleanHTML(el)
const opts = { effect: 'wght', amplitude: 200, period: 4, density: 2 }
let chars = applyFloodText(el, original)
let stop = startFloodText(chars, opts)
// On resize — re-wrap characters and restart (diagonal directions read BCR positions once):
const ro = new ResizeObserver(() => {
stop()
chars = applyFloodText(el, original)
stop = startFloodText(chars, opts)
})
ro.observe(el)
// Later — stop the animation loop and restore the DOM:
stop()
ro.disconnect()
removeFloodText(el, original)TypeScript
import type { FloodTextOptions, FloodEffect } from '@liiift-studio/floodtext'
const effects: FloodEffect[] = ['wght', 'oblique']
const opts: FloodTextOptions = { effect: effects, period: 4 }Options
| Option | Default | Description |
|--------|---------|-------------|
| effect | 'wght' | 'wght' | 'wdth' | 'oblique' | 'opacity' | 'rotation' | 'blur' | 'size'. Pass an array to layer multiple effects simultaneously. Note: oblique requires Chrome 87+, Firefox 88+, Safari 14.1+. size causes layout recalculation per frame — use low amplitude |
| amplitude | auto | Peak deviation from neutral. Used in single-effect mode. Defaults: wght 200, wdth 20, oblique 15°, opacity 0.3, rotation 15°, blur 2px, size 0.15em |
| amplitudes | — | Per-effect overrides when layering multiple effects, e.g. { wght: 300, blur: 3 } |
| properties | — | Custom CSS properties or variables to animate per character. Each entry: { property, base, amplitude, unit?, clamp? } where clamp is an optional [min, max] pair to cap the result (e.g. [0, 1] for opacity). E.g. [{ property: 'letter-spacing', base: 0, amplitude: 0.05, unit: 'em' }] or [{ property: '--my-axis', base: 100, amplitude: 20, clamp: [50, 150] }] |
| period | 4 | Seconds per full wave cycle |
| density | 2 | Wave cycles visible across the paragraph at once. Higher = more bands |
| direction | 'diagonal-down' | 'diagonal-down' ↘ | 'diagonal-up' ↗ | 'right' → | 'left' ←. Diagonal directions use 2D screen coordinates; right/left use sequential character index |
| waveShape | 'sine' | 'sine' | 'sawtooth' | 'triangle' |
| as | 'p' | HTML element to render, e.g. 'h1', 'span'. (React component only) |
How it works
Every visible character is wrapped in an inline <span>. Whitespace is left as bare text nodes — no layout impact, no reflow. Each frame, the wave function is evaluated at that character's normalised position in the paragraph. The density option controls how many wave cycles are visible at once.
For direction: 'right' or 'left', position is the sequential character index — no DOM reads in the animation loop. For diagonal directions, each character's 2D screen coordinates are read via getBoundingClientRect once before the loop starts and projected onto the diagonal axis. Speed is framerate-independent; the loop excludes time the tab was hidden to prevent phase jumps when re-entering focus. In React, the animation loop is tied to the component lifecycle and stops automatically on unmount. The React hook skips the animation entirely if prefers-reduced-motion: reduce is set.
Line break safety: Character spans are inline (not inline-block), so applying styles per character does not change line widths or word breaks. The browser's natural line-breaking is fully preserved.
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
- Pause/resume API — explicit
pause()andresume()methods rather than stop-and-restart, so animation phase is preserved across pauses - Intersection Observer — automatic pause when the element scrolls out of view; resume on re-entry
- Per-character easing — apply a custom easing curve to individual character offsets, not just the raw wave value
- More built-in effects —
hue(color hue rotation),shadow(text-shadow offset),skew(CSS skewX) - SSR-compatible static snapshot — render a stable mid-wave frame on the server so there is no FOUC before hydration
Current version: v1.0.3
