# Wave text

> A sine wave runs through the line.

- Type: Component (physics)
- Page: https://textlab.dev/components/wave-text
- Markdown: https://textlab.dev/components/wave-text.md
- Registry item: https://textlab.dev/r/wave-text.json
- Source file: `registry/textlab/wave-text/wave-text.tsx`
- Dependencies: none
- Variants: wave, bounce, float
- Keywords: wave, bounce, wavy text, sine, float, loop

## When to use

- Loading states, celebrations and playful labels.
- Short words that should feel alive.

## When not to use

- Anything people must read carefully.
- Several looping effects on the same screen.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/wave-text
pnpm dlx shadcn@latest add @textlab/wave-text
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

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

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { WaveText } from "@/components/textlab/wave-text/wave-text";

export function Example() {
  return <WaveText text="Hello, sailor" variant="bounce" />;
}
```

## API reference

### WaveText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"wave" \| "bounce" \| "float"` | `"wave"` | A smooth sine, hops off the baseline, or a gentle drift and tilt. |
| `amplitude` | `number` | `0.2` | Wave height in em. |
| `speed` | `number` | `1.6` | Seconds per cycle. |
| `spread` | `number` | `0.08` | Seconds between neighbouring letters. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- Letters are aria-hidden; the text is read once.
- It never stops on its own, so keep it short and decorative.

## Motion

- Negative delays start every letter mid-cycle, so the wave is already in motion on load.

## Reduced motion

Still, plain text.

## Performance

- Pure CSS keyframes on transform; no JavaScript after render.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: wave, bounce, float.

## Related

- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
- [Path text](https://textlab.dev/components/path-text.md): Text travels along a wave, circle or spiral.

## Also in physics

- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.

## Source

### components/textlab/wave-text/wave-text.tsx

```tsx
"use client";

import type { CSSProperties, ElementType } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type WaveTextVariant = "wave" | "bounce" | "float";

export interface WaveTextProps {
  text: string;
  /** wave rolls a smooth sine, bounce hops each letter off the baseline, float drifts and tilts gently. */
  variant?: WaveTextVariant;
  /** Height of the wave in em. */
  amplitude?: number;
  /** Seconds per cycle. */
  speed?: number;
  /** Seconds between neighbouring letters. Smaller makes a longer wave. */
  spread?: number;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

const CSS = `
@keyframes tl-wave { 0%, 100% { transform: translateY(calc(var(--tl-amp) * 0.5)); } 50% { transform: translateY(calc(var(--tl-amp) * -0.5)); } }
@keyframes tl-bounce { 0%, 100% { transform: translateY(0); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 50% { transform: translateY(calc(var(--tl-amp) * -1)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } }
@keyframes tl-float { 0%, 100% { transform: translateY(0) rotate(-3deg); } 50% { transform: translateY(calc(var(--tl-amp) * -0.6)) rotate(3deg); } }
@media (prefers-reduced-motion: reduce) { [data-tl-wave="user"] [data-tl-char] { animation: none !important; } }
`;

/** A sine wave runs through the line. Pure CSS: one keyframe, offset per letter. */
export function WaveText({ text, variant = "wave", amplitude = 0.2, speed = 1.6, spread = 0.08, as: Tag = "span", reducedMotion = "user", className, style }: WaveTextProps) {
  const reduced = useReduced(reducedMotion);
  if (reduced) {
    return (
      <Tag className={className} style={style}>
        {text}
      </Tag>
    );
  }
  const name = variant === "bounce" ? "tl-bounce" : variant === "float" ? "tl-float" : "tl-wave";
  return (
    <Tag className={className} style={{ "--tl-amp": `${amplitude}em`, ...style } as CSSProperties} data-tl-wave={reducedMotion === "user" ? "user" : undefined}>
      <style href="tl-wave-text" precedence="default">{CSS}</style>
      <SrText>{text}</SrText>
      <span aria-hidden="true">
        <Letters
          text={text}
          charStyle={(i) => ({
            animation: `${name} ${speed}s ease-in-out infinite`,
            // Negative delays start every letter mid-cycle, so the wave is already moving on first paint.
            animationDelay: `${-(10 - ((i * spread) % 10))}s`,
            transformOrigin: "50% 80%",
          })}
        />
      </span>
    </Tag>
  );
}

export default WaveText;
```

Full library index: https://textlab.dev/llms.txt
