# Caret morph

> A caret that glides between words and reshapes to fit.

- Type: Component (typing)
- Page: https://textlab.dev/components/caret-morph
- Markdown: https://textlab.dev/components/caret-morph.md
- Registry item: https://textlab.dev/r/caret-morph.json
- Source file: `registry/textlab/caret-morph/caret-morph.tsx`
- Dependencies: motion
- Variants: highlight, bar, block
- Keywords: caret, cursor, selection, highlight, editor, text cursor

## When to use

- Editor and writing-tool marketing where the caret is the product.
- Interactive pull quotes.

## When not to use

- Real text inputs; use the browser caret.
- Long paragraphs; the tour takes too long to be noticed.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/caret-morph
pnpm dlx shadcn@latest add @textlab/caret-morph
```

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/caret-morph.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 { CaretMorph } from "@/components/textlab/caret-morph/caret-morph";

export function Example() {
  return <CaretMorph text="Point at any word in this sentence" />;
}
```

## API reference

### CaretMorph

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"highlight" \| "bar" \| "block"` | `"highlight"` | highlight selects the word, bar sits after it like a cursor, block inverts it. |
| `autoplay` | `boolean` | `true` | Tour the words while nobody is pointing. |
| `interval` | `number` | `1.3` | Seconds per word on the tour. |
| `color` | `string` | `var(--tl-accent)` | Highlight color. |
| `as` | `ElementType` | `"p"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- The sentence is focusable and named; Left and Right move the caret.
- Pointer and focus pause the tour.

## Motion

- The caret glides in 200 ms with a strong ease-out and retargets mid-move, so hopping across words stays quick.
- Position and width ride one transform, so the glide never touches layout.

## Reduced motion

The caret is hidden; the sentence is plain text.

## Performance

- One absolutely positioned element. Word boxes are measured on resize, not per frame.

## 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: highlight, bar, block.

## Related

- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.

## Also in typing

- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.

## Source

### components/textlab/caret-morph/caret-morph.tsx

```tsx
"use client";

import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ElementType, type KeyboardEvent } from "react";
import { motion } from "motion/react";
import { duration, ease } from "../lib/motion-tokens";
import { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type CaretMorphVariant = "bar" | "block" | "highlight";

export interface CaretMorphProps {
  text: string;
  /** bar sits after the word like a cursor, block inverts the word, highlight selects it. */
  variant?: CaretMorphVariant;
  /** Tour the words on its own while nobody is pointing. */
  autoplay?: boolean;
  /** Seconds per word on the tour. */
  interval?: number;
  /** Highlight color for the highlight variant. */
  color?: string;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

type Box = { x: number; y: number; w: number; h: number };

/**
 * A caret that glides between words and reshapes to fit them. Point at a word, or focus the
 * sentence and use the arrow keys, and the caret springs there.
 */
export function CaretMorph({ text, variant = "highlight", autoplay = true, interval = 1.3, color = "var(--tl-accent, #3a9a5b)", as: Tag = "p", reducedMotion = "user", className, style }: CaretMorphProps) {
  const reduced = useReduced(reducedMotion);
  const words = splitWords(text);
  const root = useRef<HTMLElement | null>(null);
  const [boxes, setBoxes] = useState<Box[]>([]);
  const [index, setIndex] = useState(0);
  const [engaged, setEngaged] = useState(false);

  useLayoutEffect(() => {
    const node = root.current;
    if (!node) return;
    const measure = () => {
      const spans = node.querySelectorAll<HTMLElement>("[data-tl-word]");
      // Measure against the root's own box: offsetLeft is relative to the nearest positioned ancestor,
      // which here is an inline wrapper whose origin moves when the sentence wraps.
      const origin = node.getBoundingClientRect();
      setBoxes(
        Array.from(spans, (s) => {
          const r = s.getBoundingClientRect();
          return { x: r.left - origin.left, y: r.top - origin.top, w: r.width, h: r.height };
        }),
      );
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(node);
    document.fonts?.ready.then(measure);
    return () => observer.disconnect();
  }, [text]);

  useEffect(() => {
    if (!autoplay || engaged || reduced || words.length < 2) return;
    const id = setInterval(() => setIndex((i) => (i + 1) % words.length), interval * 1000);
    return () => clearInterval(id);
  }, [autoplay, engaged, reduced, words.length, interval]);

  const onKeyDown = (event: KeyboardEvent) => {
    if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
    event.preventDefault();
    setIndex((i) => (i + (event.key === "ArrowRight" ? 1 : -1) + words.length) % words.length);
  };

  const box = boxes[index];
  // The caret is a fixed 100px-wide box moved and stretched with one transform, so the glide never
  // touches layout. Its height only changes with the line height, which does not animate.
  const BASE = 100;
  const caret: CSSProperties = { position: "absolute", left: 0, top: 0, width: BASE, pointerEvents: "none", transformOrigin: "0 0", borderRadius: variant === "bar" ? 2 : "0.12em" };
  const frame = box
    ? variant === "bar"
      ? { x: box.x + box.w + 2, y: box.y + box.h * 0.12, w: Math.max(2, box.h * 0.06), h: box.h * 0.78 }
      : { x: box.x - box.h * 0.08, y: box.y + box.h * 0.04, w: box.w + box.h * 0.16, h: box.h * 0.92 }
    : null;
  const target = frame ? { transform: `translate(${frame.x.toFixed(1)}px, ${frame.y.toFixed(1)}px) scaleX(${(frame.w / BASE).toFixed(4)})` } : null;

  return (
    <Tag
      ref={root}
      className={className}
      style={{ position: "relative", ...style }}
      tabIndex={0}
      role="group"
      aria-label={text}
      onKeyDown={onKeyDown}
      onFocus={() => setEngaged(true)}
      onBlur={() => setEngaged(false)}
      onPointerLeave={() => setEngaged(false)}
    >
      {target && !reduced && (
        <motion.span
          aria-hidden="true"
          style={{
            ...caret,
            height: frame?.h,
            background: variant === "highlight" ? `color-mix(in oklab, ${color} 28%, transparent)` : "currentColor",
            mixBlendMode: variant === "block" ? "difference" : undefined,
            zIndex: variant === "block" ? 2 : 0,
          }}
          initial={false}
          animate={target}
          transition={{ duration: duration.base * 0.85, ease: [...ease.enter] }}
        />
      )}
      <span aria-hidden="true">
        {words.map((word, i) => (
          <span key={i}>
            <span
              data-tl-word=""
              onPointerEnter={() => {
                setEngaged(true);
                setIndex(i);
              }}
              style={{ position: "relative", zIndex: 1 }}
            >
              {word.text}
            </span>
            {i < words.length - 1 ? " " : null}
          </span>
        ))}
      </span>
    </Tag>
  );
}

export default CaretMorph;
```

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