# Wave Text

> Letters lift and settle one after another, like a wave running through the word: on hover, or as a gentle loop.

Category: Text · Free · Updated 2026-10-22 · Live demo: https://motionkitui.com/components/wave-text

## When to use it

Friendly greetings, playful nav links, buttons and small headings on brands with a lighter tone.

## How it works

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.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/wave-text.json
```

Or copy `components/motionkit/wave-text.tsx` below. Requires react, motion and Tailwind CSS v4.

## Props

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

## Accessibility

- **Keyboard: Pass.** On hover mode, focusing the text (or the link around it) plays the wave too.
- **Screen readers: Pass.** The word is read once from a visually hidden copy; the moving letters are hidden.
- **Reduced motion: Pass.** With reduced motion on, the letters never move. Hover is the default, so nothing loops unless you ask it to.
- **Colour contrast: Not applicable.** Inherits your text colour.

## Source

```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>
  );
}
```
