# Magnetic text

> Letters lean toward the cursor and spring back.

- Type: Component (cursor)
- Page: https://textlab.dev/components/magnetic-text
- Markdown: https://textlab.dev/components/magnetic-text.md
- Registry item: https://textlab.dev/r/magnetic-text.json
- Source file: `registry/textlab/magnetic-text/magnetic-text.tsx`
- Dependencies: none
- Variants: attract, repel
- Keywords: magnetic, cursor, hover, attract, repel, interactive text

## When to use

- Hero words and footers that reward exploring with the mouse.
- Playful portfolios.

## When not to use

- Touch-only layouts; nothing happens without a mouse or pen.
- Long text; every letter is tracked.

## Installation

### CLI

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

export function Example() {
  return <MagneticText as="h2" text="Come closer" />;
}
```

## API reference

### MagneticText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"attract" \| "repel"` | `"attract"` | Lean toward the cursor, or shy away from it. |
| `radius` | `number` | `2.5` | Reach of the field in em. |
| `strength` | `number` | `0.28` | Largest offset in em. |
| `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.
- Offsets are small, so words stay readable while they move.

## Motion

- Pull follows a smoothstep falloff and each letter eases toward its target.
- Letters glide home when the pointer leaves.

## Reduced motion

Still, plain text.

## Performance

- Letter centers are measured once and on resize; the frame loop only writes transforms.

## 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: attract, repel.

## Related

- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.

## Also in cursor

- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.

## Source

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

```tsx
"use client";

import { useRef, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { damp } from "../lib/math";
import { splitWords } from "../lib/split";
import { useFrame } from "../lib/use-frame";
import { useLetterBoxes } from "../lib/use-letter-boxes";
import { usePointer } from "../lib/use-pointer";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type MagneticTextVariant = "attract" | "repel";

export interface MagneticTextProps {
  text: string;
  /** attract leans letters toward the cursor, repel pushes them away. */
  variant?: MagneticTextVariant;
  /** Reach of the field, in em. */
  radius?: number;
  /** Largest offset, in em. */
  strength?: number;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * Letters lean toward the cursor and spring back when it leaves. The pull fades smoothly with
 * distance, so only the letters near the pointer move.
 */
export function MagneticText({ text, variant = "attract", radius = 2.5, strength = 0.28, as: Tag = "span", reducedMotion = "user", className, style }: MagneticTextProps) {
  const root = useRef<HTMLElement | null>(null);
  const reduced = useReduced(reducedMotion);
  const pointer = usePointer(root, { enabled: !reduced });
  const { boxes, em: emRef } = useLetterBoxes(root, [text]);
  const offsets = useRef<{ x: number; y: number }[]>([]);
  const words = splitWords(text);

  useFrame(
    (_, dt) => {
      const node = root.current;
      if (!node) return;
      const em = emRef.current;
      const reach = radius * em;
      const max = strength * em;
      const p = pointer.current;
      const sign = variant === "attract" ? 1 : -1;
      boxes.current.forEach((box, i) => {
        const o = (offsets.current[i] ??= { x: 0, y: 0 });
        let tx = 0;
        let ty = 0;
        if (p.active) {
          const dx = p.x - box.cx;
          const dy = p.y - box.cy;
          const dist = Math.hypot(dx, dy);
          if (dist < reach) {
            // Smoothstep falloff: full pull at the center, none at the edge of the field.
            const t = 1 - dist / reach;
            const pull = t * t * (3 - 2 * t) * max * sign;
            tx = (dx / (dist || 1)) * pull;
            ty = (dy / (dist || 1)) * pull;
          }
        }
        o.x = damp(o.x, tx, 14, dt);
        o.y = damp(o.y, ty, 14, dt);
        box.el.style.transform = `translate(${o.x.toFixed(2)}px, ${o.y.toFixed(2)}px)`;
      });
    },
    { ref: root, enabled: !reduced },
  );

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

  return (
    <Tag ref={root} className={className} style={{ position: "relative", display: "inline-block", ...style }}>
      <SrText>{text}</SrText>
      <span aria-hidden="true">
        {words.map((word, w) => (
          <span key={w}>
            <span style={{ display: "inline-block", whiteSpace: "nowrap" }}>
              {word.chars.map((char, c) => (
                <span key={c} data-tl-char="" style={{ display: "inline-block" }}>
                  {char}
                </span>
              ))}
            </span>
            {w < words.length - 1 ? " " : null}
          </span>
        ))}
      </span>
    </Tag>
  );
}

export default MagneticText;
```

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