character-wave animation
A wave washes through the body copy 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.
Live demo — watch the paragraph
How it works
Per-character phase
Every visible character (a grapheme, so emoji sequences, accents and Indic clusters stay whole) is wrapped in an inline span, and the text stays in the DOM, so screen readers read it as before. Each frame, the wave function is evaluated at that character’s position in the text — normalised across the whole paragraph. The density option controls how many wave cycles are visible at once.
Traveling wave
The wave advances through the characters over time on one shared requestAnimationFrame loop. Speed is consistent regardless of display refresh rate, and the loop cleans up on unmount. Whitespace is left as bare text nodes, and the browser’s own line breaks are locked while the wave runs, so words never move between lines.
Accessibility & compatibility
On e-ink and slow-refresh displays (update: slow media feature — Kindle, Remarkable, and similar panels), the wave animation produces no visible effect. FloodText detects this automatically: the element is restored to its original HTML and all animation work is skipped. The prefers-reduced-motion: reduce preference is also honoured — when set, the animation is skipped entirely and the element is left in its original state. The demo above respects this preference too: if reduced motion is set in your OS, the text will remain static.
Usage
Drop-in component
import { FloodText } from '@overpunch/floodtext'
<FloodText effect="wght" amplitude={200} period={4} density={2} direction="diagonal-down">
Your paragraph text here...
</FloodText>Hook
import { useFloodText } from '@overpunch/floodtext'
const ref = useFloodText({ effect: 'wght', amplitude: 200, period: 4, density: 2, direction: 'diagonal-down' })
<p ref={ref}>{children}</p>Vanilla JS
// React-free entry: no react import
import { applyFloodText, startFloodText, removeFloodText, getCleanHTML } from '@overpunch/floodtext/core'
const el = document.querySelector('p')
const original = getCleanHTML(el)
const opts = { effect: 'wght', amplitude: 200, period: 4, density: 2, direction: 'diagonal-down' }
const chars = applyFloodText(el, original, opts)
const stop = startFloodText(chars, opts)
// The lines are locked while the wave runs: when the container's
// width changes, stop() and run applyFloodText + startFloodText again.
// Later — stop animation and restore:
stop()
removeFloodText(el, original)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. |
| source | 'fixed' | 'fixed' — all characters share the same amplitude. 'sentiment' — per-word AFINN emotional valence scores scale the amplitude. Uses the sentiment package, an optional dependency npm installs with floodText; it loads only in this mode. Scores arrive asynchronously: the hook re-runs by itself; in vanilla JS, await loadSentiment() first. Falls back to 'fixed' if the package is missing. Not available in the Webflow embed. |
| amplitude | auto | Peak deviation. Used for single-effect mode. wght, wdth and oblique oscillate around the text's own weight, width and slant (bold stays bold, italic stays italic). Defaults: wght 200, wdth 20, oblique 15deg, opacity 0.3, rotation 15deg, blur 2px, size 0.15em. |
| amplitudes | — | Per-effect amplitude overrides when layering multiple effects. E.g. { wght: 300, blur: 3 }. |
| properties | — | Animate any CSS property or CSS custom property on each character, driven by the same wave. Each entry: { property, base, amplitude, unit?, clamp? }. Works with CSS variables. E.g. [{ property: 'letter-spacing', base: 0, amplitude: 0.05, unit: 'em' }] or [{ property: '--my-axis', base: 100, amplitude: 20 }]. |
| period | 4 | Seconds per full wave cycle. Minimum 0.5 (faster waves would flash more than twice a second). |
| 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 character positions. |
| waveShape | 'sine' | 'sine' | 'sawtooth' | 'triangle' |
| pauseOffscreen | true | Pause the animation when the element scrolls out of view; resume when visible. Uses IntersectionObserver internally. |
| as | 'p' | HTML element to render, e.g. 'h1', 'span'. Accepts any valid React element type. (FloodText component only) |
no-code
Use it in Webflow, Framer & Figma
The same effect, no build step — drop it straight into your design tool.
Webflow
One script tag, then mark any element with data-floodtext. Configure it with data-* attributes.
<!-- Site Settings → Custom Code → Footer, or an Embed element -->
<script src="https://cdn.jsdelivr.net/npm/@overpunch/floodtext/dist/floodtext.webflow.min.js"></script>
<!-- Then add data-floodtext to any text element -->
<h1 data-floodtext>Your headline</h1>Framer
Insert → Code → New Component, then paste FloodText.tsx ↗. It imports the core from esm.sh and exposes every option in the property panel — no build step.
import { /* core */ } from "https://esm.sh/@overpunch/floodtext"Figma · beta
Part of the Type Tools Figma plugin ↗ — Plugins → Development → Import plugin from manifest, run Type Tools, and pick this tool. Here it bakes a single static frame of the animated effect (no live animation in Figma).