Wave Text Effect
Original · freewavy letter animation
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/txfx-wave.json"use client";
import { useId, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Theme = {
id: string;
name: string;
gradient: string;
accent: string;
ring: string;
chip: string;
bar: string;
};
const THEMES: Theme[] = [
{
id: "aurora",
name: "Aurora",
gradient:
"from-indigo-500 to-violet-600 dark:from-indigo-300 dark:to-violet-400",
accent: "accent-indigo-500",
ring: "focus-visible:ring-indigo-500",
chip: "bg-gradient-to-br from-indigo-400 to-violet-600",
bar: "from-indigo-500 via-violet-500 to-sky-500",
},
{
id: "ember",
name: "Ember",
gradient:
"from-amber-500 to-rose-600 dark:from-amber-300 dark:to-rose-400",
accent: "accent-rose-500",
ring: "focus-visible:ring-rose-500",
chip: "bg-gradient-to-br from-amber-400 to-rose-600",
bar: "from-amber-500 via-rose-500 to-amber-500",
},
{
id: "meadow",
name: "Meadow",
gradient:
"from-emerald-500 to-sky-600 dark:from-emerald-300 dark:to-sky-400",
accent: "accent-emerald-500",
ring: "focus-visible:ring-emerald-500",
chip: "bg-gradient-to-br from-emerald-400 to-sky-600",
bar: "from-emerald-500 via-sky-500 to-emerald-500",
},
];
const DEFAULTS = {
text: "Words that move like water",
amplitude: 18,
speed: 1.4,
stagger: 0.075,
themeId: "aurora",
playing: true,
};
type WaveLetterProps = {
ch: string;
idx: number;
amplitude: number;
duration: number;
stagger: number;
active: boolean;
gradient: string;
};
function WaveLetter({
ch,
idx,
amplitude,
duration,
stagger,
active,
gradient,
}: WaveLetterProps) {
return (
<motion.span
aria-hidden="true"
className={`inline-block bg-gradient-to-b ${gradient} bg-clip-text text-transparent [will-change:transform]`}
animate={active ? { y: [0, -amplitude, 0, amplitude, 0] } : { y: 0 }}
transition={
active
? {
duration,
times: [0, 0.25, 0.5, 0.75, 1],
ease: "easeInOut",
repeat: Infinity,
delay: idx * stagger,
}
: { duration: 0.4, ease: "easeOut" }
}
>
{ch}
</motion.span>
);
}
type RangeControlProps = {
id: string;
label: string;
hint: string;
value: number;
min: number;
max: number;
step: number;
accent: string;
ring: string;
display: string;
onChange: (v: number) => void;
};
function RangeControl({
id,
label,
hint,
value,
min,
max,
step,
accent,
ring,
display,
onChange,
}: RangeControlProps) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<label
htmlFor={id}
className="text-sm font-medium text-slate-700 dark:text-slate-200"
>
{label}
</label>
<span className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-400">
{display}
</span>
</div>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
aria-valuetext={display}
onChange={(e) => onChange(Number(e.target.value))}
className={`h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 dark:bg-slate-700 ${accent} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${ring}`}
/>
<p className="text-xs text-slate-400 dark:text-slate-500">{hint}</p>
</div>
);
}
export default function TxfxWave() {
const reduce = useReducedMotion();
const uid = useId();
const [text, setText] = useState(DEFAULTS.text);
const [amplitude, setAmplitude] = useState(DEFAULTS.amplitude);
const [speed, setSpeed] = useState(DEFAULTS.speed);
const [stagger, setStagger] = useState(DEFAULTS.stagger);
const [themeId, setThemeId] = useState(DEFAULTS.themeId);
const [playing, setPlaying] = useState(DEFAULTS.playing);
const theme = THEMES.find((t) => t.id === themeId) ?? THEMES[0];
const active = playing && !reduce;
const duration = 1.8 / speed;
const model = useMemo(() => {
const source = text.length > 0 ? text : " ";
const words = source.split(" ");
let idx = 0;
return words.map((word, wi) => {
const letters = Array.from(word).map((ch) => ({ ch, idx: idx++ }));
const withSpace = wi < words.length - 1;
if (withSpace) idx += 1;
return { key: `w-${wi}`, letters, withSpace };
});
}, [text]);
const reset = () => {
setText(DEFAULTS.text);
setAmplitude(DEFAULTS.amplitude);
setSpeed(DEFAULTS.speed);
setStagger(DEFAULTS.stagger);
setThemeId(DEFAULTS.themeId);
setPlaying(DEFAULTS.playing);
};
const status = reduce
? "Motion is disabled by your system's reduced-motion setting."
: `Wave ${active ? "playing" : "paused"}, height ${amplitude} pixels, speed ${speed.toFixed(
1
)} times, spread ${Math.round(stagger * 1000)} milliseconds.`;
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-slate-900 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100 sm:py-28">
<style>{`
@keyframes txfxwave-shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes txfxwave-float {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(22px, -18px) scale(1.08); }
}
@keyframes txfxwave-float-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-26px, 20px) scale(1.06); }
}
.txfxwave-bar {
background-size: 200% auto;
animation: txfxwave-shimmer 3.5s linear infinite;
}
.txfxwave-blob { animation: txfxwave-float 15s ease-in-out infinite; }
.txfxwave-blob.alt { animation: txfxwave-float-2 17s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.txfxwave-bar, .txfxwave-blob { animation: none !important; }
}
`}</style>
{/* Ambient background */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="txfxwave-blob absolute -left-24 top-4 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="txfxwave-blob alt absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-600/20" />
<div
className="absolute inset-0 opacity-[0.5] dark:opacity-[0.25]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgb(100 116 139 / 0.25) 1px, transparent 0)",
backgroundSize: "26px 26px",
}}
/>
</div>
<div className="relative z-10 mx-auto max-w-4xl">
<div className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 rounded-full ${theme.chip}`}
aria-hidden="true"
/>
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">
Text effects · Per-letter motion
</span>
</div>
<h1 className="mt-6 text-balance text-4xl font-black leading-[1.08] tracking-tight sm:text-6xl">
<span className="sr-only">{text || "Wavy text preview"}</span>
<span aria-hidden="true" className="inline">
{model.map((word) => (
<span key={word.key} className="inline-block whitespace-nowrap">
{word.letters.map((l) => (
<WaveLetter
key={`${word.key}-${l.idx}`}
ch={l.ch}
idx={l.idx}
amplitude={amplitude}
duration={duration}
stagger={stagger}
active={active}
gradient={theme.gradient}
/>
))}
{word.withSpace ? <span> </span> : null}
</span>
))}
</span>
</h1>
<div
className={`txfxwave-bar mt-7 h-1 w-40 rounded-full bg-gradient-to-r ${theme.bar}`}
aria-hidden="true"
/>
<p className="mt-6 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
A per-letter wave built with real controls. Tune the height, speed,
and spread, pick a palette, then type your own line and watch each
character ride the curve.
</p>
{/* Control panel */}
<div className="mt-10 rounded-2xl border border-slate-200 bg-white/70 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/60 sm:p-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
disabled={!!reduce}
aria-pressed={active}
className={`inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-950 ${theme.ring}`}
>
{active ? (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="currentColor"
>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="currentColor"
>
<path d="M8 5.14v13.72a1 1 0 0 0 1.5.86l11-6.86a1 1 0 0 0 0-1.72l-11-6.86A1 1 0 0 0 8 5.14Z" />
</svg>
)}
{reduce ? "Motion off" : active ? "Pause" : "Play"}
</button>
<div
role="group"
aria-label="Colour palette"
className="flex flex-wrap items-center gap-2"
>
{THEMES.map((t) => {
const selected = t.id === themeId;
return (
<button
key={t.id}
type="button"
onClick={() => setThemeId(t.id)}
aria-pressed={selected}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${t.ring} ${
selected
? "border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-slate-600"
}`}
>
<span
className={`h-3 w-3 rounded-full ${t.chip}`}
aria-hidden="true"
/>
{t.name}
</button>
);
})}
</div>
</div>
<div className="mt-8 grid gap-6 sm:grid-cols-3">
<RangeControl
id={`${uid}-amp`}
label="Wave height"
hint="How far each letter rises and falls."
value={amplitude}
min={4}
max={48}
step={2}
accent={theme.accent}
ring={theme.ring}
display={`${amplitude}px`}
onChange={setAmplitude}
/>
<RangeControl
id={`${uid}-speed`}
label="Wave speed"
hint="Cycles faster as you turn it up."
value={speed}
min={0.5}
max={3}
step={0.1}
accent={theme.accent}
ring={theme.ring}
display={`${speed.toFixed(1)}×`}
onChange={setSpeed}
/>
<RangeControl
id={`${uid}-spread`}
label="Wave spread"
hint="Delay between neighbouring letters."
value={stagger}
min={0.02}
max={0.16}
step={0.005}
accent={theme.accent}
ring={theme.ring}
display={`${Math.round(stagger * 1000)}ms`}
onChange={setStagger}
/>
</div>
<div className="mt-8 flex flex-col gap-2">
<label
htmlFor={`${uid}-text`}
className="text-sm font-medium text-slate-700 dark:text-slate-200"
>
Your text
</label>
<div className="flex flex-col gap-3 sm:flex-row">
<input
id={`${uid}-text`}
type="text"
value={text}
maxLength={48}
placeholder="Type something to animate…"
onChange={(e) => setText(e.target.value)}
className={`w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-950 ${theme.ring}`}
/>
<button
type="button"
onClick={reset}
className={`shrink-0 rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${theme.ring}`}
>
Reset
</button>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500">
{text.length}/48 characters · Tab to each control, then use arrow
keys to adjust.
</p>
</div>
</div>
<p aria-live="polite" className="sr-only">
{status}
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Aurora Gradient Headline
OriginalAn animated headline whose living multi-colour gradient drifts across every letter while the words blur and stagger into place on scroll.

Character Reveal Heading
OriginalA cinematic heading that blurs and lifts in one character at a time with a staggered, scroll-triggered reveal.

Shimmer Sweep Headline
OriginalA headline with a polished light sheen that glides endlessly across the letters and races faster on hover.

Rotating Words Headline
OriginalA headline whose final word flips through a curated set with a blurred vertical roll while the rest of the line stays perfectly still.

Gradient Animate Text Effect
Originalanimated gradient text

Typing Text Effect
Originaltypewriter text with caret

Shiny Text Effect
Originalshiny sweep text

Glitch Text Effect
Originalglitch text effect

Reveal Mask Text Effect
Originalmask reveal heading

Outline Text Effect
Originaloutline-to-fill text

3D Text Effect
Originallayered 3D text

Scramble Text Effect
Originalscramble/decode text

