Letters lift and settle one after another, like a wave running through the word: on hover, or as a gentle loop.
Customize
Usage
import { WaveText } from "@/components/motionkit/wave-text";
<WaveText text="Hello there" className="text-6xl" />Motion
The exact curves this component runs, plotted with Motion's own maths.
Letter lift
Hover or focus
Friendly greetings, playful nav links, buttons and small headings on brands with a lighter tone.
Each letter is its own inline-block span. A wave is a keyframed lift from 0 up to the chosen height and back, started one letter after another with a short stagger. In hover mode every pointer entry or focus remounts the letters with a fresh key, so a new wave starts even if the last one hasn't finished; loop mode repeats with a rest between waves.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/wave-text.jsonnpm install motionSave as components/motionkit/wave-text.tsx.
"use client";
import { useState, type ElementType } from "react";
import { motion, useReducedMotion } from "motion/react";
type WaveTextProps = {
text: string;
/** How high each letter lifts, in em. */
height?: number;
/** Seconds between one letter and the next. */
stagger?: number;
/** "hover" plays one wave each time the pointer enters; "loop" keeps waving. */
trigger?: "hover" | "loop";
/** Element to render, e.g. "h2". */
as?: ElementType;
className?: string;
};
/** Letters lift and settle one after another, like a wave running through the word. */
export function WaveText({ text, height = 0.25, stagger = 0.045, trigger = "hover", as: Tag = "span", className = "" }: WaveTextProps) {
const reduceMotion = useReducedMotion();
// every hover starts a fresh wave, even if the last one hasn't finished
const [wave, setWave] = useState(0);
const looping = trigger === "loop" && !reduceMotion;
return (
<Tag
onPointerEnter={() => trigger === "hover" && setWave((n) => n + 1)}
onFocus={() => trigger === "hover" && setWave((n) => n + 1)}
className={`inline-block ${className}`}
>
<span className="sr-only">{text}</span>
<span aria-hidden>
{[...text].map((letter, index) => (
<motion.span
key={`${index}-${looping ? "loop" : wave}`}
className="inline-block whitespace-pre"
initial={false}
animate={reduceMotion || (!looping && wave === 0) ? undefined : { y: ["0em", `-${height}em`, "0em"] }}
transition={{
duration: 0.6,
delay: index * stagger,
ease: "easeInOut",
...(looping && { repeat: Infinity, repeatDelay: Math.max(0.4, text.length * stagger) }),
}}
>
{letter}
</motion.span>
))}
</span>
</Tag>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The word or short phrase. |
| height | number | 0.25 | How high each letter lifts, in em. |
| stagger | number | 0.045 | Seconds between letters. |
| trigger | "hover" | "loop" | "hover" | One wave per hover, or a continuous loop. |
| as | ElementType | "span" | Element to render. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Only the motion package, beside React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/wave-text.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, the letters never move. Hover is the default, so nothing loops unless you ask it to.