# Elastic text

> Drag a letter and the word stretches like rubber.

- Type: Component (physics)
- Page: https://textlab.dev/components/elastic-text
- Markdown: https://textlab.dev/components/elastic-text.md
- Registry item: https://textlab.dev/r/elastic-text.json
- Source file: `registry/textlab/elastic-text/elastic-text.tsx`
- Dependencies: none
- Variants: band, sling
- Keywords: elastic, rubber band, drag, stretch, spring, physics

## When to use

- Toy-like hero moments and interactive posters.
- Touch devices, where dragging feels natural.

## When not to use

- Text inside scrolling lists; dragging captures the pointer.
- Anything people should select and copy.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/elastic-text
pnpm dlx shadcn@latest add @textlab/elastic-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/elastic-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 { ElasticText } from "@/components/textlab/elastic-text/elastic-text";

export function Example() {
  return <ElasticText as="h2" text="Pull me" variant="sling" />;
}
```

## API reference

### ElasticText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"band" \| "sling"` | `"band"` | Stretch and ease back, or snap back hard and overshoot. |
| `reach` | `number` | `3` | How many neighbours follow a pulled letter. |
| `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 word is read once.
- Dragging is decorative; nothing depends on it.

## Motion

- Pull resistance grows with distance, like rubber.
- Neighbours follow with exponential falloff; release springs them home.

## Reduced motion

Plain text; dragging does nothing.

## Performance

- Springs update only while something is moving.

## 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: band, sling.

## Related

- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.

## 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/elastic-text/elastic-text.tsx

```tsx
"use client";

import { useRef, type CSSProperties, type ElementType, type PointerEvent } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { atRest, stepSpring, type Spring1D } from "../lib/physics";
import { useFrame } from "../lib/use-frame";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type ElasticTextVariant = "band" | "sling";

export interface ElasticTextProps {
  text: string;
  /** band stretches and eases back; sling snaps back hard and overshoots like a released slingshot. */
  variant?: ElasticTextVariant;
  /** How many neighbours come along when you pull a letter. */
  reach?: number;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * Drag a letter and the word stretches like rubber. Neighbours follow with falloff, and letting go
 * springs everything home. Works with mouse, pen and touch.
 */
export function ElasticText({ text, variant = "band", reach = 3, as: Tag = "span", reducedMotion = "user", className, style }: ElasticTextProps) {
  const root = useRef<HTMLElement | null>(null);
  const reduced = useReduced(reducedMotion);
  const springs = useRef<{ x: Spring1D; y: Spring1D }[]>([]);
  const drag = useRef<{ index: number; startX: number; startY: number; dx: number; dy: number } | null>(null);
  const live = useRef(false);

  const elements = () => Array.from(root.current?.querySelectorAll<HTMLElement>("[data-tl-char]") ?? []);

  const onDown = (event: PointerEvent, index: number) => {
    if (reduced) return;
    (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
    const count = elements().length;
    for (let i = 0; i < count; i++) springs.current[i] ??= { x: { x: 0, v: 0 }, y: { x: 0, v: 0 } };
    drag.current = { index, startX: event.clientX, startY: event.clientY, dx: 0, dy: 0 };
    live.current = true;
  };
  const onMove = (event: PointerEvent) => {
    const d = drag.current;
    if (!d) return;
    // Resistance grows with distance, like rubber that gets harder to pull.
    const rawX = event.clientX - d.startX;
    const rawY = event.clientY - d.startY;
    const dist = Math.hypot(rawX, rawY) || 1;
    const eased = 140 * Math.tanh(dist / 140);
    d.dx = (rawX / dist) * eased;
    d.dy = (rawY / dist) * eased;
  };
  const onUp = () => {
    const d = drag.current;
    if (!d) return;
    if (variant === "sling") {
      springs.current.forEach((s) => {
        s.x.v += -s.x.x * 6;
        s.y.v += -s.y.x * 6;
      });
    }
    drag.current = null;
  };

  useFrame(
    (_, dt) => {
      if (!live.current) return;
      const els = elements();
      const d = drag.current;
      let moving = !!d;
      springs.current.forEach((s, i) => {
        if (!s) return;
        const fall = d ? Math.exp(-Math.abs(i - d.index) / Math.max(0.5, reach / 2)) : 0;
        const tx = d ? d.dx * fall : 0;
        const ty = d ? d.dy * fall : 0;
        const stiffness = d ? 900 : variant === "sling" ? 520 : 300;
        const damping = d ? 60 : variant === "sling" ? 9 : 16;
        stepSpring(s.x, tx, stiffness, damping, dt);
        stepSpring(s.y, ty, stiffness, damping, dt);
        if (!atRest(s.x, tx, 0.05) || !atRest(s.y, ty, 0.05)) moving = true;
        const el = els[i];
        if (!el) return;
        // Letters stretch along the pull, which sells the rubber.
        const len = Math.hypot(s.x.x, s.y.x);
        const angle = Math.atan2(s.y.x, s.x.x);
        const stretch = 1 + Math.min(len / 220, 0.45);
        el.style.transform = `translate(${s.x.x.toFixed(2)}px, ${s.y.x.toFixed(2)}px) rotate(${angle}rad) scaleX(${stretch.toFixed(3)}) rotate(${-angle}rad)`;
      });
      live.current = moving;
    },
    { ref: root, enabled: !reduced },
  );

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

  return (
    <Tag ref={root} className={className} style={{ display: "inline-block", touchAction: "none", userSelect: "none", ...style }}>
      <SrText>{text}</SrText>
      <span aria-hidden="true">
        <Letters
          text={text}
          charStyle={{ cursor: "grab", willChange: "transform" }}
          render={(char, i) => (
            <span style={{ display: "inline-block" }} onPointerDown={(e) => onDown(e, i)} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}>
              {char}
            </span>
          )}
        />
      </span>
    </Tag>
  );
}

export default ElasticText;
```

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