# Letter drop

> Letters fall, bounce and settle on the floor.

- Type: Component (physics)
- Page: https://textlab.dev/components/letter-drop
- Markdown: https://textlab.dev/components/letter-drop.md
- Registry item: https://textlab.dev/r/letter-drop.json
- Source file: `registry/textlab/letter-drop/letter-drop.tsx`
- Dependencies: none
- Variants: drop, pile, toss
- Keywords: physics, gravity, bounce, drop, falling letters, toss

## When to use

- Playful launches, games and kids' products.
- A single word that should land with weight.

## When not to use

- Serious contexts.
- Long sentences; each letter is a body.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/letter-drop
pnpm dlx shadcn@latest add @textlab/letter-drop
```

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/letter-drop.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 { LetterDrop } from "@/components/textlab/letter-drop/letter-drop";

export function Example() {
  return <LetterDrop as="h2" text="Gravity" variant="pile" />;
}
```

## API reference

### LetterDrop

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"drop" \| "pile" \| "toss"` | `"drop"` | Fall straight and bounce, tumble and land askew, or also fling up on click or tap. |
| `height` | `number` | `2.2` | Fall height in em. |
| `restitution` | `number` | `0.45` | Bounciness, 0 to 0.9. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `trigger` | `"mount" \| "inView" \| "manual"` | `"inView"` | What starts the animation. manual follows `active`. |
| `active` | `boolean` | `false` | Drives trigger="manual". |
| `once` | `boolean` | `true` | In view: play once instead of on every entry. |
| `stagger` | `number` | `0.06` | Seconds between parts. |
| `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 word is read once.
- Toss works with click and tap; it is a bonus, never needed to read the word.

## Motion

- Real gravity with energy lost on every bounce.
- Pile letters spin as they fall and settle up to 8 degrees off true.

## Reduced motion

The word sits on the baseline from the start.

## Performance

- One frame loop that stops once every letter has settled.

## 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: drop, pile, toss.

## Related

- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.

## Also in physics

- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.

## Source

### components/textlab/letter-drop/letter-drop.tsx

```tsx
"use client";

import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { hashString, seeded } from "../lib/math";
import { graphemes } from "../lib/split";
import { useFrame } from "../lib/use-frame";
import { useHydrated } from "../lib/use-hydrated";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";

export type LetterDropVariant = "drop" | "pile" | "toss";

export interface LetterDropProps {
  text: string;
  /** drop falls straight and bounces, pile tumbles and lands askew, toss also flings letters up on click or tap. */
  variant?: LetterDropVariant;
  /** Fall height in em. */
  height?: number;
  /** Bounciness, 0 to 0.9. */
  restitution?: number;
  as?: ElementType;
  trigger?: Exclude<Trigger, "always" | "hover">;
  active?: boolean;
  once?: boolean;
  /** Seconds between letters starting to fall. */
  stagger?: number;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

type Body = { y: number; v: number; r: number; vr: number; start: number; restR: number; settled: boolean };

/**
 * Letters fall, bounce and settle on the baseline under real gravity. Each letter is its own body
 * with a bounce that loses energy, and pile mode lets them tumble and land a little crooked.
 */
export function LetterDrop({ text, variant = "drop", height = 2.2, restitution = 0.45, as: Tag = "span", trigger = "inView", active, once = true, stagger = 0.06, reducedMotion = "user", className, style }: LetterDropProps) {
  const { ref, on, bind } = useTrigger<HTMLElement>({ trigger, active, once });
  const hydrated = useHydrated();
  const reduced = useReduced(reducedMotion);
  const count = graphemes(text.replace(/\s+/g, "")).length;
  const bodies = useRef<Body[]>([]);
  const els = useRef<HTMLElement[]>([]);
  const clock = useRef(0);
  const running = useRef(false);

  useEffect(() => {
    const node = ref.current;
    if (!node) return;
    els.current = Array.from(node.querySelectorAll<HTMLElement>("[data-tl-char]"));
  }, [ref, text]);

  useEffect(() => {
    if (reduced) return;
    const rand = seeded(hashString(text));
    bodies.current = Array.from({ length: count }, (_, i) => ({
      y: -height,
      v: 0,
      r: variant === "pile" ? (rand() - 0.5) * 120 : 0,
      vr: 0,
      start: i * stagger + rand() * stagger,
      restR: variant === "pile" ? (rand() - 0.5) * 16 : 0,
      settled: false,
    }));
    clock.current = 0;
    running.current = on;
  }, [on, reduced, text, count, height, stagger, variant]);

  const toss = () => {
    if (variant !== "toss" || reduced) return;
    const rand = seeded(Date.now() >>> 0);
    bodies.current.forEach((b) => {
      b.v = -(6 + rand() * 5);
      b.vr = (rand() - 0.5) * 900;
      b.settled = false;
      b.start = 0;
    });
    clock.current = 0;
    running.current = true;
  };

  useFrame(
    (_, dt) => {
      if (!running.current) return;
      clock.current += dt;
      const gravity = 38;
      let moving = false;
      bodies.current.forEach((b, i) => {
        const el = els.current[i];
        if (!el) return;
        if (clock.current < b.start || b.settled) {
          if (!b.settled) moving = true;
          el.style.transform = `translateY(${b.y}em) rotate(${b.r}deg)`;
          el.style.opacity = clock.current < b.start && b.y <= -height ? "0" : "1";
          return;
        }
        moving = true;
        b.v += gravity * dt;
        b.y += b.v * dt;
        b.r += b.vr * dt;
        if (b.y >= 0) {
          b.y = 0;
          b.v = -b.v * restitution;
          b.vr = (b.restR - b.r) * 4 + b.vr * -0.3;
          if (Math.abs(b.v) < 0.8) {
            b.v = 0;
            b.settled = true;
            b.r = b.restR;
          }
        }
        el.style.opacity = "1";
        el.style.transform = `translateY(${b.y.toFixed(3)}em) rotate(${b.r.toFixed(1)}deg)`;
      });
      if (!moving) running.current = false;
    },
    { ref, enabled: !reduced },
  );

  if (reduced) {
    return (
      <Tag ref={ref} className={className} style={style} {...bind}>
        {text}
      </Tag>
    );
  }

  return (
    <Tag
      ref={ref}
      className={className}
      style={{ display: "inline-block", cursor: variant === "toss" ? "pointer" : undefined, ...style }}
      onPointerDown={toss}
      data-tl-motion={reducedMotion === "user" ? "user" : undefined}
      data-tl-pending={hydrated ? undefined : ""}
      {...bind}
    >
      <SrText>{text}</SrText>
      <span aria-hidden="true">
        <Letters text={text} part charStyle={{ opacity: 0, transform: `translateY(${-height}em)`, transformOrigin: "50% 100%" }} />
      </span>
    </Tag>
  );
}

export default LetterDrop;
```

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