# Jot: Hand-Drawn Annotation Arrow for React

> A handwritten note that draws its own arrow to the thing it's about. It writes itself in when it scrolls into view, keeps the arrow attached as the layout moves, swings it over when you point it somewhere new, and rubs itself out once it has done its job.

Hand-Drawn Annotation Arrow for React: a handwritten note draws its arrow to your UI. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/jot
- Tags: Annotation, SVG, Onboarding
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [ToDesktop](https://www.todesktop.com/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/jot.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/jot
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/jot.json
```

## Usage

The main demo shows typical usage.

### Annotated Hero

A form builder's hero. Two notes write themselves in, one curling its arrow up to the call to action, the other pointing at the live reply count.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/jot-demo
```

```tsx title="components/jot-demo.tsx"
"use client";

import * as React from "react";

import { Jot } from "@/components/ui/jot";

// The last twelve hours of replies, oldest first; the newest bar grows as replies come in.
const HOURS = [18, 26, 22, 35, 41, 38, 52, 61, 48, 57, 66, 44];

export function JotDemo() {
  const ctaRef = React.useRef<HTMLAnchorElement>(null);
  const statsRef = React.useRef<HTMLDivElement>(null);
  const [replies, setReplies] = React.useState(1284);

  // A reply or two every couple of seconds, so "live" is true.
  React.useEffect(() => {
    const timer = window.setInterval(() => setReplies((n) => n + 1 + Math.floor(Math.random() * 2)), 2200);
    return () => window.clearInterval(timer);
  }, []);

  const latest = HOURS[HOURS.length - 1] + (replies - 1284) * 2;
  const top = Math.max(...HOURS, latest);

  return (
    <div data-jot-scope className="@container/jot relative w-full max-w-[56rem] px-2 pt-24 pb-32 @lg/jot:px-6 @3xl/jot:pt-28 @3xl/jot:pb-40">
      <div className="mx-auto grid max-w-[38rem] gap-8 rounded-[1.75rem] border border-zinc-950/10 bg-white p-6 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.12)] @lg/jot:grid-cols-[1fr_12rem] @lg/jot:p-8 dark:border-white/10 dark:bg-zinc-900 dark:shadow-none">
        <div className="flex flex-col">
          <p className="flex items-center gap-2 text-[13px] font-medium text-zinc-500 dark:text-zinc-400">
            <svg viewBox="0 0 20 20" aria-hidden className="size-5">
              <rect width="20" height="20" rx="6" className="fill-zinc-950 dark:fill-white" />
              <path
                d="M5.5 7.5 7.6 13l2.4-5 2.4 5 2.1-5.5"
                className="fill-none stroke-white dark:stroke-zinc-950"
                strokeWidth="1.6"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
            Wren Forms
          </p>
          <h2 className="mt-5 text-[2rem]/[1.05] font-semibold tracking-[-0.03em] text-balance text-zinc-950 dark:text-white">
            Forms people actually finish.
          </h2>
          <p className="mt-3 max-w-[30ch] text-[15px]/6 text-pretty text-zinc-600 dark:text-zinc-400">
            One question at a time, saved as they type, easy to answer on a phone.
          </p>
          <div className="mt-7 flex flex-wrap items-center gap-x-5 gap-y-3">
            <a
              ref={ctaRef}
              href="#build"
              className="inline-flex h-10 items-center gap-1.5 rounded-full bg-zinc-950 px-5 text-sm font-medium text-white outline-none hover:bg-zinc-800 focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200 dark:focus-visible:ring-white dark:focus-visible:ring-offset-zinc-900"
            >
              Build a form
              <svg viewBox="0 0 16 16" aria-hidden className="size-4">
                <path d="M6 4l4 4-4 4" className="fill-none stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </a>
            <a
              href="#templates"
              className="rounded-sm text-sm font-medium text-zinc-600 underline decoration-zinc-300 underline-offset-4 outline-none hover:text-zinc-950 hover:decoration-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950 dark:text-zinc-400 dark:decoration-zinc-700 dark:hover:text-white dark:hover:decoration-white dark:focus-visible:ring-white"
            >
              See templates
            </a>
          </div>
        </div>

        <div
          ref={statsRef}
          className="order-first self-end rounded-2xl @lg/jot:order-none border border-zinc-950/[0.07] bg-zinc-50 p-4 dark:border-white/[0.07] dark:bg-zinc-950/60"
        >
          <div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
            <span>Replies today</span>
            <span className="flex items-center gap-1.5">
              <span className="size-1.5 rounded-full bg-emerald-500" />
              Live
            </span>
          </div>
          <p className="mt-1.5 text-[1.75rem]/none font-semibold tracking-tight text-zinc-950 tabular-nums dark:text-white">
            {replies.toLocaleString("en-US")}
          </p>
          <div className="mt-4 flex h-12 items-end gap-[3px]" aria-hidden>
            {[...HOURS.slice(0, -1), latest].map((h, i) => (
              <span
                key={i}
                className={
                  i === HOURS.length - 1
                    ? "flex-1 rounded-[2px] bg-zinc-950 transition-[height] duration-500 ease-out dark:bg-white"
                    : "flex-1 rounded-[2px] bg-zinc-300 dark:bg-zinc-700"
                }
                style={{ height: `${(h / top) * 100}%` }}
              />
            ))}
          </div>
          <p className="mt-3 flex justify-between text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
            <span>Finish rate</span>
            <span className="font-medium text-zinc-950 dark:text-white">71%</span>
          </p>
        </div>
      </div>

      <Jot
        to={ctaRef}
        loop
        bend={-0.35}
        tilt={-3}
        className="absolute bottom-5 left-4 max-w-44 text-xl/snug text-blue-600 @lg/jot:left-6 @3xl/jot:bottom-7 @3xl/jot:left-4 dark:text-blue-400"
      >
        free for your first 1,000 replies
      </Jot>
      <Jot
        to={statsRef}
        bend={0.3}
        tilt={2}
        delay={0.7}
        className="absolute top-4 right-4 max-w-none text-xl/snug text-blue-600 @lg/jot:right-6 @3xl/jot:top-6 @3xl/jot:right-4 dark:text-blue-400"
      >
        real replies,
        <br />
        counted as they land
      </Jot>
    </div>
  );
}
```

## Examples

Finished sections built with Jot. Each one installs whole, with the component included.

### Follow the Pick

Three plans as radio cards. The note swings its arrow to whichever plan you pick and rewrites itself for it.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/jot-plans-demo
```

```tsx title="components/jot-plans-demo.tsx"
"use client";

import * as React from "react";

import { Jot } from "@/components/ui/jot";

const PLANS = [
  {
    id: "solo",
    name: "Solo",
    price: 0,
    blurb: "One client, every file",
    terms: "Free for good, no card needed",
    note: "free forever, no card needed",
  },
  {
    id: "studio",
    name: "Studio",
    price: 24,
    blurb: "Ten clients, your logo",
    terms: "14 days free, then $24 a month",
    note: "most studios start here",
  },
  {
    id: "agency",
    name: "Agency",
    price: 79,
    blurb: "Every client, with SSO",
    terms: "14 days free, then $79 a month",
    note: "for when you outgrow ten",
  },
] as const;

type PlanId = (typeof PLANS)[number]["id"];

export function JotPlansDemo() {
  const name = React.useId();
  const [picked, setPicked] = React.useState<PlanId>("studio");
  const plan = PLANS.find((p) => p.id === picked) ?? PLANS[1];
  const index = PLANS.indexOf(plan);

  return (
    <div data-jot-scope className="@container/plans relative w-full max-w-[38rem] px-2 pt-24 pb-4">
      <fieldset>
        <legend className="text-[13px] font-medium text-zinc-500 dark:text-zinc-400">Lantern plans</legend>
        <p className="mt-1 max-w-[16ch] text-xl/tight font-semibold tracking-[-0.02em] text-balance text-zinc-950 dark:text-white">
          Client portals for small studios
        </p>

        <div className="mt-6 grid grid-cols-3 gap-2 @md/plans:gap-3">
          {PLANS.map((p) => (
            <label
              key={p.id}
              data-plan={p.id}
              className="group relative flex cursor-pointer flex-col rounded-2xl border border-zinc-950/10 bg-white p-3 transition-[border-color,box-shadow] duration-200 ease-out hover:border-zinc-950/25 has-checked:border-zinc-950 has-checked:shadow-[0_0_0_1px_var(--color-zinc-950)] has-focus-visible:ring-2 has-focus-visible:ring-blue-500 has-focus-visible:ring-offset-2 @md/plans:p-4 dark:border-white/10 dark:bg-zinc-900 dark:hover:border-white/25 dark:has-checked:border-white dark:has-checked:shadow-[0_0_0_1px_var(--color-white)] dark:has-focus-visible:ring-offset-zinc-950"
            >
              <input
                type="radio"
                name={name}
                value={p.id}
                checked={picked === p.id}
                onChange={() => setPicked(p.id)}
                className="sr-only"
              />
              <span className="flex items-center justify-between gap-2">
                <span className="text-sm font-medium text-zinc-950 dark:text-white">{p.name}</span>
                <span
                  aria-hidden
                  className="grid size-4 place-items-center rounded-full border border-zinc-950/20 transition-colors duration-150 group-has-checked:border-zinc-950 dark:border-white/25 dark:group-has-checked:border-white"
                >
                  <span className="size-2 scale-50 rounded-full bg-zinc-950 opacity-0 transition-[scale,opacity] duration-150 ease-out group-has-checked:scale-100 group-has-checked:opacity-100 dark:bg-white" />
                </span>
              </span>
              <span className="mt-4 flex items-baseline gap-0.5 text-zinc-950 dark:text-white">
                <span className="text-2xl/none font-semibold tracking-tight tabular-nums @md/plans:text-[1.75rem]/none">
                  ${p.price}
                </span>
                <span className="text-xs text-zinc-500 dark:text-zinc-400">/mo</span>
              </span>
              <span className="mt-2 text-xs/4 text-pretty text-zinc-500 dark:text-zinc-400">{p.blurb}</span>
            </label>
          ))}
        </div>
      </fieldset>

      <div className="mt-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-3 rounded-2xl bg-zinc-100/80 py-2.5 pr-2.5 pl-4 dark:bg-white/5">
        <p className="text-sm text-zinc-600 dark:text-zinc-400" aria-live="polite">
          {plan.terms}
        </p>
        <a
          href="#start"
          className="inline-flex h-9 items-center rounded-full bg-zinc-950 px-4 text-sm font-medium text-white outline-none hover:bg-zinc-800 focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200 dark:focus-visible:ring-white dark:focus-visible:ring-offset-zinc-950"
        >
          Start with {plan.name}
        </a>
      </div>

      <Jot
        to={`[data-plan="${picked}"]`}
        at="top"
        // Leave sideways and land heading down: bow up toward whichever side the plan is on.
        bend={index === 2 ? -0.45 : 0.45}
        tilt={-2}
        trigger="mount"
        delay={0.3}
        className="absolute top-4 right-[8%] text-xl/snug text-blue-600 @md/plans:right-[14%] dark:text-blue-400"
      >
        {plan.note}
      </Jot>
    </div>
  );
}
```

### Hints That Retire

A file-sharing card where each hint rubs itself out once you've done what it asks, and the next one writes in.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/jot-onboarding-demo
```

```tsx title="components/jot-onboarding-demo.tsx"
"use client";

import * as React from "react";

import { Jot } from "@/components/ui/jot";

const FILE = { name: "Brand guidelines.pdf", size: "4.2 MB" };
const LINK = "parcel.to/k7f2q";

type Step = "empty" | "uploading" | "ready" | "sent";

export function JotOnboardingDemo() {
  const [step, setStep] = React.useState<Step>("empty");
  const dropRef = React.useRef<HTMLButtonElement>(null);
  const copyRef = React.useRef<HTMLButtonElement>(null);

  // A short, pretend upload, so the second hint waits for the file to land.
  React.useEffect(() => {
    if (step !== "uploading") return;
    const timer = window.setTimeout(() => setStep("ready"), 900);
    return () => window.clearTimeout(timer);
  }, [step]);

  const hasFile = step !== "empty";
  const sent = step === "sent";

  return (
    <div
      data-jot-scope
      className="@container/share relative grid w-full max-w-[52rem] justify-items-center px-2 pt-20 pb-24 @2xl/share:px-0 @2xl/share:py-10"
    >
      <div className="w-full max-w-[22rem] rounded-[1.5rem] border border-zinc-950/10 bg-white p-5 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.12)] dark:border-white/10 dark:bg-zinc-900 dark:shadow-none">
        <div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
          <span className="flex items-center gap-1.5 font-medium text-zinc-950 dark:text-white">
            <svg viewBox="0 0 16 16" aria-hidden className="size-4">
              <path d="M2.5 5.2 8 2.5l5.5 2.7v5.6L8 13.5l-5.5-2.7z" className="fill-amber-400" />
              <path d="M2.5 5.2 8 8l5.5-2.8M8 8v5.5" className="fill-none stroke-amber-700" strokeWidth="1.1" strokeLinejoin="round" />
            </svg>
            Parcel
          </span>
          Links last 7 days
        </div>
        <p className="mt-4 text-lg/tight font-semibold tracking-[-0.02em] text-zinc-950 dark:text-white">Send files</p>

        <button
          ref={dropRef}
          type="button"
          disabled={hasFile}
          onClick={() => setStep("uploading")}
          className="mt-3 grid w-full justify-items-center gap-1 rounded-2xl border border-dashed border-zinc-950/20 bg-zinc-50 px-4 py-6 text-center outline-none transition-colors duration-150 enabled:hover:border-zinc-950/40 enabled:hover:bg-zinc-100/70 focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-default disabled:opacity-50 dark:border-white/15 dark:bg-white/[0.03] dark:enabled:hover:border-white/30 dark:enabled:hover:bg-white/[0.06]"
        >
          <svg viewBox="0 0 20 20" aria-hidden className="size-5 text-zinc-500 dark:text-zinc-400">
            <path d="M10 13V4m0 0L6.5 7.5M10 4l3.5 3.5M4 13.5v1A1.5 1.5 0 0 0 5.5 16h9a1.5 1.5 0 0 0 1.5-1.5v-1" className="fill-none stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          <span className="text-sm font-medium text-zinc-950 dark:text-white">Drop files here</span>
          <span className="text-xs text-zinc-500 dark:text-zinc-400">or click to add a sample file</span>
        </button>

        <div className="mt-3 flex h-12 items-center gap-3 rounded-xl px-1" aria-live="polite">
          {hasFile ? (
            <>
              <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-red-50 text-[10px] font-semibold text-red-600 dark:bg-red-500/10 dark:text-red-400">
                PDF
              </span>
              <span className="min-w-0 flex-1">
                <span className="block truncate text-sm font-medium text-zinc-950 dark:text-white">{FILE.name}</span>
                <span className="mt-1 block h-1 overflow-hidden rounded-full bg-zinc-100 dark:bg-white/10">
                  <span
                    className={
                      step === "uploading"
                        ? "block h-full w-full origin-left rounded-full bg-zinc-950 transition-transform duration-[900ms] ease-out starting:scale-x-0 dark:bg-white"
                        : "block h-full w-full rounded-full bg-emerald-500"
                    }
                  />
                </span>
              </span>
              <span className="text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
                {step === "uploading" ? "Uploading" : FILE.size}
              </span>
            </>
          ) : (
            <span className="w-full text-center text-xs text-zinc-400 dark:text-zinc-500">No files yet</span>
          )}
        </div>

        <div className="mt-3 flex items-center gap-2 border-t border-zinc-950/[0.06] pt-4 dark:border-white/[0.06]">
          <span className="min-w-0 flex-1 truncate rounded-full bg-zinc-100 px-3 py-2 font-mono text-xs text-zinc-500 dark:bg-white/5 dark:text-zinc-400">
            {step === "ready" || sent ? LINK : "Your link shows up here"}
          </span>
          <button
            ref={copyRef}
            type="button"
            disabled={step !== "ready" && !sent}
            onClick={() => setStep("sent")}
            className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-zinc-950 px-4 text-sm font-medium text-white outline-none transition-opacity duration-150 enabled:hover:bg-zinc-800 focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:opacity-35 dark:bg-white dark:text-zinc-950 dark:enabled:hover:bg-zinc-200 dark:focus-visible:ring-white dark:focus-visible:ring-offset-zinc-900"
          >
            {sent ? (
              <>
                <svg viewBox="0 0 16 16" aria-hidden className="size-4">
                  <path d="m3.5 8.5 3 3 6-7" className="fill-none stroke-current" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
                Copied
              </>
            ) : (
              "Copy link"
            )}
          </button>
        </div>
      </div>

      <button
        type="button"
        onClick={() => setStep("empty")}
        className={
          "mt-4 rounded-sm text-xs font-medium text-zinc-500 underline decoration-zinc-300 underline-offset-4 outline-none transition-opacity duration-200 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950 dark:text-zinc-400 dark:decoration-zinc-700 dark:hover:text-white " +
          (sent ? "opacity-100" : "pointer-events-none opacity-0")
        }
        tabIndex={sent ? 0 : -1}
        aria-hidden={!sent}
      >
        Start over
      </button>

      <Jot
        to={dropRef}
        show={step === "empty"}
        bend={0.3}
        tilt={-3}
        className="absolute top-4 left-4 text-xl/snug text-blue-600 @2xl/share:top-[30%] @2xl/share:left-4 dark:text-blue-400"
      >
        start here: drop
        <br />
        anything in
      </Jot>
      <Jot
        to={copyRef}
        show={step === "ready"}
        bend={0.35}
        tilt={2}
        className="absolute right-4 bottom-6 text-xl/snug text-blue-600 @2xl/share:top-[62%] @2xl/share:right-4 @2xl/share:bottom-auto dark:text-blue-400"
      >
        now grab the link
      </Jot>
      <Jot
        show={sent}
        tilt={-2}
        className="absolute bottom-6 left-4 text-xl/snug text-emerald-600 @2xl/share:top-[64%] @2xl/share:bottom-auto @2xl/share:left-4 dark:text-emerald-400"
      >
        that&apos;s the whole thing
      </Jot>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `React.ReactNode` |  | The words. Keep them short: a few words on one or two lines. |
| `to` | `React.RefObject<Element \| null> \| string` |  | What the arrow points at: a ref, or a CSS selector looked up inside the nearest `[data-jot-scope]` ancestor (the whole document without one). Leave it out for a note with no arrow. |
| `from` | `"auto" \| "top" \| "right" \| "bottom" \| "left"` | `"auto"` | The side of the note the arrow leaves from. `"auto"` picks the side facing the target. |
| `at` | `"auto" \| "top" \| "right" \| "bottom" \| "left"` | `"auto"` | The side of the target the arrow lands on. `"auto"` aims at its center and stops at its edge. |
| `bend` | `number` | `0.35` | How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way. |
| `loop` | `boolean` | `false` | Curl the arrow once midway, like a quick flourish. |
| `gap` | `number` | `6` | Pixels left between the arrowhead and the target. |
| `tilt` | `number` | `0` | Degrees to turn the words, for a looser hand. The arrow isn't turned. |
| `trigger` | `"view" \| "mount"` | `"view"` | When it writes itself: once half of it is on screen, or as soon as it mounts. |
| `delay` | `number` | `0` | Seconds to wait before the first write, to write several notes one after another. |
| `speed` | `number` | `1` | How fast the pen writes, draws the arrow and rubs out, as a multiple of the default pace. `2` is twice as fast. |
| `show` | `boolean` | `true` | Set to false to rub the note out; true writes it back in. |
| `describe` | `boolean` | `true` | Describe the target with the note's words for screen readers, through `aria-describedby`. |
| `className` | `string` |  | Extra classes for the note: where it sits, its text color (the arrow takes it too), size and width. The words use a stack of system handwriting fonts; set your own with a `font-*` class. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/jot.tsx`, keep these, which make it feel and behave right:

- The pen lives outside React: it reads both boxes in motion's `frame.read` and paints in `frame.render`, only while something could move a box, and never off screen.
- The words are real text shown by a `clip-path` wipe, line by line; the strokes draw by `strokeDasharray` on `pathLength={1}` paths, and an undrawn one is hidden outright, since a zero-length dash still paints a round cap.
- The pen writes the words, sets off on the shaft before the last word is done, then draws the head's two strokes; rubbing out runs backward: head, shaft, words.
- The tremor is seeded from the words and fades out at both ends, so the line never shimmers and still starts and lands exactly.
- While shown, the target lists the note in `aria-describedby`; reduced motion drops the pen and fades the note and arrow in and out by opacity.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/jot.tsx"
"use client";

import * as React from "react";
import {
  animate,
  cancelFrame,
  cubicBezier,
  frame,
  useReducedMotion,
  type AnimationPlaybackControls,
} from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - The pen lives outside React: it reads both boxes in motion's `frame.read` and paints in
 *   `frame.render`, only while something could move a box, and never off screen.
 * - The words are real text shown by a `clip-path` wipe, line by line; the strokes draw by
 *   `strokeDasharray` on `pathLength={1}` paths, and an undrawn one is hidden outright, since a
 *   zero-length dash still paints a round cap.
 * - The pen writes the words, sets off on the shaft before the last word is done, then draws the
 *   head's two strokes; rubbing out runs backward: head, shaft, words.
 * - The tremor is seeded from the words and fades out at both ends, so the line never shimmers
 *   and still starts and lands exactly.
 * - While shown, the target lists the note in `aria-describedby`; reduced motion drops the pen and
 *   fades the note and arrow in and out by opacity.
 */
const TIMING = {
  /** Before the first write, in seconds (the `delay` prop). */
  delay: 0,
  /** How fast the pen writes, draws and rubs out, as a multiple of the pace below (`speed`). */
  speed: 1,
  // At the default pace; `speed` divides these.
  /** Writing the words, in seconds per character. */
  letter: 0.028,
  /** The shortest and longest the words take, in seconds. */
  write: [0.35, 0.7],
  /** Drawing the shaft, in px/s. */
  pen: 650,
  /** The shortest and longest the shaft takes, in seconds. */
  draw: [0.25, 0.5],
  /** How long before the last word is done the shaft sets off, in seconds. */
  lead: 0.1,
  /** Each of the head's two strokes, in seconds. */
  stroke: 0.1,
  /** Between the head's two strokes, in seconds. */
  stagger: 0.04,
  /** Every stroke eases in and out, like a hand, as a cubic bezier. */
  hand: [0.65, 0, 0.35, 1],
  /** Old words wiping out before the new ones write in, in seconds. */
  unwrite: 0.15,
  /** Rubbing out, head first, in seconds each. */
  rub: { head: 0.06, shaft: 0.2, words: 0.16 },
  /** How long before the part ahead of it is gone each part starts rubbing out, in seconds. */
  rubOverlap: { shaft: 0.02, words: 0.04 },
  /** The arrow swinging over to a new target, as a spring (not paced by `speed`). */
  swing: { type: "spring", visualDuration: 0.45, bounce: 0.12 },
  /** Reduced motion: the note and its arrow fading in, in seconds. */
  fadeIn: 0.2,
  /** Reduced motion: fading out, in seconds. */
  fadeOut: 0.15,
  /** Reading boxes every frame after anything that could move one, in ms. */
  watch: 700,
  /** Reading on after a transition or scroll ends, in ms. */
  tail: 250,
  /** After this, a transition that never reported its end is forgotten, in ms. */
  stale: 4000,
} as const;

/* ─────────────────────────────────────────────────────────
 * JOT STORYBOARD
 *
 *  rest     a few handwritten words in the margin and nothing drawn
 *           yet. The words are real text in your text color, so
 *           screen readers have them from the start
 *  write    once half the note is on screen (or at mount), the pen
 *           writes the words left to right, line by line, at about
 *           28ms a letter (0.35–0.7s in all)
 *  draw     0.1s before the last word is done the pen sets off from
 *           the side of the note facing the target and draws the
 *           arrow to it at ~650px/s (0.25–0.5s), easing in and out
 *           like a hand. It bows by `bend`, curls once midway with
 *           `loop`, strays ±0.8px either side of true (seeded by the
 *           words, so it never shimmers) and stops `gap` px short of
 *           the target's edge
 *  head     the head goes on last: two short strokes, 11px and 9px,
 *           0.1s each and 0.04s apart
 *  attach   whenever something could move a box (a resize, a scroll,
 *           a transition, a re-render) both boxes are read every frame
 *           until things settle, so the arrow stays attached through
 *           reflows, hover lifts and layout animations. When nothing
 *           moves it stops reading, and off screen it sleeps
 *  swing    point `to` somewhere new and the arrow swings over on a
 *           spring (0.45s, a small 0.12 settle) while the words stay.
 *           New words wipe the old ones out (0.15s) and write in
 *  rub      `show={false}` rubs it out in reverse: the head, then the
 *           shaft back into the note, then the words, ~0.35s in all.
 *           `show` again writes it back from wherever it got to
 *  describe while it shows, the target takes the words as its
 *           description (aria-describedby), so a screen reader on the
 *           button hears the note too
 *  reduced  no pen: the note and its arrow fade in (0.2s) and out
 *           (0.15s), and a new target fades the arrow out and back in
 * ───────────────────────────────────────────────────────── */

const JOT = {
  wobble: 0.8, // px the line strays either side of true
  barbs: [11, 9], // px; the head's two strokes
  spread: [27, 31], // degrees each stroke leans off the shaft
  inset: 4, // px between the words and where the arrow leaves them
  shortest: 14, // px; closer than this and no arrow is drawn
  view: 0.5, // share of the note on screen before it writes
} as const;

const handEase = cubicBezier(...TIMING.hand);

type Side = "auto" | "top" | "right" | "bottom" | "left";
type Point = { x: number; y: number };
type Box = { x: number; y: number; w: number; h: number };
/** Start, bend control and end of the arrow, in the note's own px. */
type Route = { a: Point; c: Point; b: Point };
type Line = { l: number; r: number; t: number; b: number };
type Lines = { list: Line[]; pad: number };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const lerpPoint = (p: Point, q: Point, t: number) => ({ x: lerp(p.x, q.x, t), y: lerp(p.y, q.y, t) });
const lerpRoute = (p: Route, q: Route, t: number): Route => ({
  a: lerpPoint(p.a, q.a, t),
  c: lerpPoint(p.c, q.c, t),
  b: lerpPoint(p.b, q.b, t),
});
const sameRoute = (p: Route | null, q: Route | null) =>
  !!p &&
  !!q &&
  Math.abs(p.a.x - q.a.x) + Math.abs(p.a.y - q.a.y) + Math.abs(p.b.x - q.b.x) + Math.abs(p.b.y - q.b.y) <
    0.05 &&
  Math.abs(p.c.x - q.c.x) + Math.abs(p.c.y - q.c.y) < 0.05;
const HIDDEN = "inset(0 100% 0 0)";

/** The plain text inside a note, for its pace, its wobble and spotting new words. */
function textOf(node: React.ReactNode): string {
  if (node == null || typeof node === "boolean") return "";
  if (typeof node === "string" || typeof node === "number") return String(node);
  if (Array.isArray(node)) return node.map(textOf).join("");
  if (React.isValidElement<{ children?: React.ReactNode }>(node)) return textOf(node.props.children);
  return "";
}

/** A hand's tremor: two slow waves with phases picked from the words, so it's the same every render. */
function tremor(text: string) {
  let h = 2166136261;
  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
  const next = () => {
    h = Math.imul(h ^ (h >>> 15), 2246822507);
    h = Math.imul(h ^ (h >>> 13), 3266489909);
    return ((h ^= h >>> 16) >>> 0) / 4294967296;
  };
  const f1 = 1.2 + next() * 0.7;
  const f2 = 2.8 + next() * 1.1;
  const p1 = next();
  const p2 = next();
  return (t: number) =>
    JOT.wobble * (0.65 * Math.sin(2 * Math.PI * (f1 * t + p1)) + 0.35 * Math.sin(2 * Math.PI * (f2 * t + p2)));
}

/** The side of a box that faces a point: where a ray from its center to the point leaves it. */
function facing(box: Box, p: Point): Exclude<Side, "auto"> {
  const dx = p.x - (box.x + box.w / 2);
  const dy = p.y - (box.y + box.h / 2);
  if (Math.abs(dx) / Math.max(box.w / 2, 1) >= Math.abs(dy) / Math.max(box.h / 2, 1)) {
    return dx >= 0 ? "right" : "left";
  }
  return dy >= 0 ? "bottom" : "top";
}

/** A point just outside one side of a box, slid along it toward `toward` but kept off the corners. */
function onSide(box: Box, side: Exclude<Side, "auto">, toward: Point, out: number): Point {
  if (side === "left" || side === "right") {
    return {
      x: side === "left" ? box.x - out : box.x + box.w + out,
      y: clamp(toward.y, box.y + box.h * 0.3, box.y + box.h * 0.7),
    };
  }
  return {
    x: clamp(toward.x, box.x + box.w * 0.2, box.x + box.w * 0.8),
    y: side === "top" ? box.y - out : box.y + box.h + out,
  };
}

/** Where the line from `a` to the box's center enters the box grown by `gap`. */
function entry(a: Point, box: Box, gap: number): Point {
  const lo = { x: box.x - gap, y: box.y - gap };
  const hi = { x: box.x + box.w + gap, y: box.y + box.h + gap };
  const d = { x: box.x + box.w / 2 - a.x, y: box.y + box.h / 2 - a.y };
  let enter = 0;
  for (const axis of ["x", "y"] as const) {
    if (Math.abs(d[axis]) < 1e-6) continue;
    const t1 = (lo[axis] - a[axis]) / d[axis];
    const t2 = (hi[axis] - a[axis]) / d[axis];
    enter = Math.max(enter, Math.min(t1, t2));
  }
  enter = clamp(enter, 0, 1);
  return { x: a.x + d.x * enter, y: a.y + d.y * enter };
}

function plan(note: Box, target: Box, o: { from: Side; at: Side; bend: number; gap: number }): Route | null {
  const center = { x: target.x + target.w / 2, y: target.y + target.h / 2 };
  const a = onSide(note, o.from === "auto" ? facing(note, center) : o.from, center, JOT.inset);
  const b = o.at === "auto" ? entry(a, target, o.gap) : onSide(target, o.at, a, o.gap);
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const d = Math.hypot(dx, dy);
  if (d < JOT.shortest) return null;
  // Bow to the right of travel for a positive bend (clockwise on screen), by a share of the length.
  const bend = clamp(o.bend, -1, 1);
  return {
    a,
    b,
    c: { x: (a.x + b.x) / 2 - (dy / d) * bend * d * 0.5, y: (a.y + b.y) / 2 + (dx / d) * bend * d * 0.5 },
  };
}

/** The shaft as a sampled line with the tremor and the optional curl, plus the tip and its heading. */
function trace(r: Route, loop: boolean, bend: number, shake: (t: number) => number) {
  const { a, c, b } = r;
  const chord = Math.hypot(b.x - a.x, b.y - a.y);
  const length = (Math.hypot(c.x - a.x, c.y - a.y) + Math.hypot(b.x - c.x, b.y - c.y) + chord) / 2;
  const radius = clamp(chord * 0.08, 8, 14);
  const curl = loop && length > radius * 7;
  // The curl takes a stretch of the line about 2.6 radii long, centred halfway along.
  const span = curl ? clamp((2.6 * radius) / length, 0.08, 0.3) : 0;
  const t0 = 0.5 - span / 2;
  const ts: number[] = [];
  const step = Math.max(3 / Math.max(length, 1), 1 / 160);
  for (let t = 0; t < 1; t += step) if (!curl || t <= t0 || t >= t0 + span) ts.push(t);
  if (curl) for (let i = 1; i < 40; i++) ts.push(t0 + (span * i) / 40);
  ts.push(1);
  ts.sort((p, q) => p - q);
  const side = bend < 0 ? -1 : 1;
  let d = "";
  for (const t of ts) {
    const u = 1 - t;
    let x = u * u * a.x + 2 * u * t * c.x + t * t * b.x;
    let y = u * u * a.y + 2 * u * t * c.y + t * t * b.y;
    let tx = 2 * u * (c.x - a.x) + 2 * t * (b.x - c.x);
    let ty = 2 * u * (c.y - a.y) + 2 * t * (b.y - c.y);
    const tl = Math.hypot(tx, ty) || 1;
    tx /= tl;
    ty /= tl;
    // The tremor fades out at both ends, so the line still starts and lands exactly.
    const wobble = shake(t) * Math.sin(Math.PI * t);
    x += -ty * wobble;
    y += tx * wobble;
    if (curl && t > t0 && t < t0 + span) {
      // A circle rolled along the line: forward at its foot, back over the top, forward again.
      const th = ((t - t0) / span) * Math.PI * 2;
      x += radius * (tx * Math.sin(th) - side * ty * (1 - Math.cos(th)));
      y += radius * (ty * Math.sin(th) + side * tx * (1 - Math.cos(th)));
    }
    d += `${d ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
  }
  let hx = b.x - c.x;
  let hy = b.y - c.y;
  const hl = Math.hypot(hx, hy);
  if (hl < 0.01) {
    hx = b.x - a.x;
    hy = b.y - a.y;
  }
  const n = Math.hypot(hx, hy) || 1;
  return { d, tip: b, heading: { x: hx / n, y: hy / n } };
}

function barb(tip: Point, heading: Point, length: number, degrees: number) {
  const rad = (degrees * Math.PI) / 180;
  const bx = -heading.x;
  const by = -heading.y;
  const x = tip.x + (bx * Math.cos(rad) - by * Math.sin(rad)) * length;
  const y = tip.y + (bx * Math.sin(rad) + by * Math.cos(rad)) * length;
  return `M${tip.x.toFixed(1)} ${tip.y.toFixed(1)}L${x.toFixed(1)} ${y.toFixed(1)}`;
}

/** The words' line boxes in their own px, measured upright even when the note is tilted. */
function measureLines(words: HTMLElement, turner: HTMLElement | null, scale: number): Lines | null {
  const turned = turner?.style.rotate ?? "";
  if (turner) turner.style.rotate = "0deg";
  const box = words.getBoundingClientRect();
  const range = document.createRange();
  range.selectNodeContents(words);
  const rects = Array.from(range.getClientRects());
  if (turner) turner.style.rotate = turned;
  if (!box.width || !box.height) return null;
  const list: Line[] = [];
  for (const rect of rects) {
    if (rect.width < 0.5 || rect.height < 0.5) continue;
    const l = (rect.left - box.left) / scale;
    const r = (rect.right - box.left) / scale;
    const t = (rect.top - box.top) / scale;
    const b = (rect.bottom - box.top) / scale;
    const mid = (t + b) / 2;
    const line = list.find((x) => mid > x.t && mid < x.b);
    if (line) {
      line.l = Math.min(line.l, l);
      line.r = Math.max(line.r, r);
      line.t = Math.min(line.t, t);
      line.b = Math.max(line.b, b);
    } else list.push({ l, r, t, b });
  }
  list.sort((p, q) => p.t - q.t);
  const size = parseFloat(getComputedStyle(words).fontSize) || 16;
  return { list, pad: size * 0.4 };
}

/** A clip that shows the words up to where the pen has got: whole lines above, part of the current one. */
function wipe(lines: Lines | null, progress: number) {
  if (progress >= 1) return "none";
  if (progress <= 0 || !lines || !lines.list.length) return HIDDEN;
  const { list, pad } = lines;
  const px = (n: number) => `${n.toFixed(1)}px`;
  const left = px(Math.min(...list.map((x) => x.l)) - pad);
  const right = px(Math.max(...list.map((x) => x.r)) + pad);
  const top = px(list[0].t - pad);
  // Lines meet halfway between one's bottom and the next one's top; the last runs out to the padding.
  const below = (i: number) => px(i === list.length - 1 ? list[i].b + pad : (list[i].b + list[i + 1].t) / 2);
  let ink = progress * list.reduce((sum, x) => sum + (x.r - x.l), 0);
  for (let i = 0; i < list.length; i++) {
    const w = list[i].r - list[i].l;
    if (ink <= w || i === list.length - 1) {
      // The pen sweeps each line from just before its first letter to just past its last.
      const pen = px(list[i].l - pad + (Math.min(ink, w) / Math.max(w, 1)) * (w + 2 * pad));
      if (i === 0) return `polygon(${left} ${top}, ${pen} ${top}, ${pen} ${below(0)}, ${left} ${below(0)})`;
      return `polygon(${left} ${top}, ${right} ${top}, ${right} ${below(i - 1)}, ${pen} ${below(i - 1)}, ${pen} ${below(i)}, ${left} ${below(i)})`;
    }
    ink -= w;
  }
  return "none";
}

function addToken(el: Element, attr: string, token: string) {
  const list = (el.getAttribute(attr) ?? "").split(/\s+/).filter(Boolean);
  if (!list.includes(token)) el.setAttribute(attr, [...list, token].join(" "));
}

function removeToken(el: Element, attr: string, token: string) {
  const list = (el.getAttribute(attr) ?? "").split(/\s+/).filter((t) => t && t !== token);
  if (list.length) el.setAttribute(attr, list.join(" "));
  else el.removeAttribute(attr);
}

/* ── Pen ──────────────────────────────────────────────── */

// Everything that moves lives here, outside React. Each note reads both boxes in motion's read
// step and paints in its render step, so many notes on one page never force extra layouts.

type Els = {
  root: HTMLElement;
  turner: HTMLElement;
  words: HTMLElement;
  svg: SVGSVGElement;
  shaft: SVGPathElement;
  barbs: [SVGPathElement, SVGPathElement];
};

type PenOptions = {
  to?: React.RefObject<Element | null> | string;
  from: Side;
  at: Side;
  bend: number;
  loop: boolean;
  gap: number;
  trigger: "view" | "mount";
  delay: number;
  speed: number;
  show: boolean;
  describe: boolean;
  reduced: boolean;
  text: string;
};

type Key = "words" | "shaft" | "head" | "swing" | "ghost" | "fadeWords" | "fadeArrow";
type Run = { controls: AnimationPlaybackControls; to: number; end: number };

const HEAD = TIMING.stroke + TIMING.stagger; // s for both strokes of the head

function createPen(els: Els, id: string) {
  let o: PenOptions | null = null;
  // words / shaft / head: how much the pen has put down, 0–1. swing: from the old route to the
  // new one (springs a little past 1). ghost: old words still showing. fade*: reduced motion.
  const v: Record<Key, number> = { words: 0, shaft: 0, head: 0, swing: 1, ghost: 0, fadeWords: 1, fadeArrow: 1 };
  const runs: Partial<Record<Key, Run>> = {};
  let triggered = false;
  let wrote = false;
  let near = false; // on or near the screen
  let looping = false; // reading and painting every frame
  let until = 0; // ms; keep reading until then, in case something is still moving
  let watched: Element | null = null; // the target the resize observer is on
  // CSS transitions and animations running in the scope: element → property → when it last stirred.
  const moving = new Map<EventTarget, Map<string, number>>();
  let target: Element | null = null;
  let described: Element | null = null;
  let live: Route | null = null;
  let drawn: Route | null = null;
  let swingFrom: Route | null = null;
  let lines: Lines | null = null;
  let linesDirty = true;
  let ghost: { el: HTMLElement; lines: Lines | null } | null = null;
  let width = 0;
  let shake = tremor("");
  let painted = "";
  const observers: { disconnect(): void }[] = [];

  const now = () => performance.now() / 1000;

  // `speed` scales every stroke of the pen, writing and rubbing out alike.
  const pace = () => 1 / Math.max(0.05, o?.speed ?? TIMING.speed);
  const writeTime = () => clamp((o?.text.length ?? 0) * TIMING.letter, TIMING.write[0], TIMING.write[1]) * pace();
  const drawTime = () => {
    if (!live) return TIMING.draw[0] * pace();
    const { a, c, b } = live;
    const length = (Math.hypot(c.x - a.x, c.y - a.y) + Math.hypot(b.x - c.x, b.y - c.y) + Math.hypot(b.x - a.x, b.y - a.y)) / 2;
    return clamp(length / TIMING.pen, TIMING.draw[0], TIMING.draw[1]) * pace();
  };

  const stop = (key: Key) => {
    runs[key]?.controls.stop();
    delete runs[key];
  };

  /** Tween one value and return when it lands. A run already heading to the same place is kept. */
  const tween = (key: Key, to: number, duration: number, delay: number, ease: "hand" | "linear" = "hand", then?: () => void) => {
    const run = runs[key];
    if (run && run.to === to && !then) return run.end;
    stop(key);
    if (Math.abs(v[key] - to) < 1e-4) {
      v[key] = to;
      then?.();
      return now();
    }
    const wait = Math.max(delay, 0);
    const controls = animate(v[key], to, {
      duration: Math.max(duration, 0.001),
      delay: wait,
      ease: ease === "hand" ? handEase : (t: number) => t,
      onUpdate: (value) => {
        v[key] = value;
      },
      onComplete: () => {
        v[key] = to;
        if (runs[key]?.controls === controls) delete runs[key];
        then?.();
      },
    });
    const end = now() + wait + duration;
    runs[key] = { controls, to, end };
    poke(0);
    return end;
  };

  /** Bring every value to where it should be, in pen order: words, shaft, head, or the reverse. */
  const sync = () => {
    if (!o) return;
    const wantWords = triggered && o.show;
    const wantArrow = wantWords && !!live;

    if (o.reduced) {
      // No pen: what's there is all there, and opacity carries the change.
      stop("words");
      stop("shaft");
      stop("head");
      if (wantWords) {
        if (v.words < 1) {
          v.words = 1;
          v.fadeWords = 0;
        }
        tween("fadeWords", 1, TIMING.fadeIn * (1 - v.fadeWords), 0, "linear");
      } else tween("fadeWords", 0, TIMING.fadeOut * v.fadeWords, 0, "linear");
      if (wantArrow) {
        if (v.shaft < 1 || v.head < 1) {
          v.shaft = 1;
          v.head = 1;
          v.fadeArrow = 0;
        }
        tween("fadeArrow", 1, TIMING.fadeIn * (1 - v.fadeArrow), 0, "linear");
      } else tween("fadeArrow", 0, TIMING.fadeOut * v.fadeArrow, 0, "linear");
      return;
    }

    stop("fadeWords");
    stop("fadeArrow");
    v.fadeWords = 1;
    v.fadeArrow = 1;
    const t = now();

    if (wantWords) {
      const delay = wrote ? 0 : o.delay;
      wrote = true;
      const wordsEnd = tween("words", 1, (1 - v.words) * writeTime(), delay);
      if (wantArrow) {
        const start = Math.max(t, wordsEnd - TIMING.lead * pace());
        const shaftEnd = tween("shaft", 1, (1 - v.shaft) * drawTime(), start - t);
        tween("head", 1, (1 - v.head) * HEAD * pace(), shaftEnd - t, "linear");
      } else rubArrow();
      return;
    }

    const shaftEnd = rubArrow();
    tween("words", 0, v.words * TIMING.rub.words * pace(), Math.max(0, shaftEnd - t - TIMING.rubOverlap.words * pace()));
  };

  /** Head first, then the shaft back into the note. Returns when the shaft is gone. */
  const rubArrow = () => {
    const t = now();
    const headEnd = tween("head", 0, v.head * TIMING.rub.head * pace(), 0, "linear");
    return tween("shaft", 0, v.shaft * TIMING.rub.shaft * pace(), Math.max(0, headEnd - t - TIMING.rubOverlap.shaft * pace()));
  };

  /** Swing from whatever is drawn now to the live route, if anything is drawn yet. */
  const startSwing = () => {
    if (!drawn || v.shaft <= 0) return;
    swingFrom = drawn;
    stop("swing");
    v.swing = 0;
    if (o?.reduced) {
      // Fade the arrow out where it is, then back in at its new place.
      tween("fadeArrow", 0, TIMING.fadeOut * v.fadeArrow, 0, "linear", () => {
        v.swing = 1;
        swingFrom = null;
        tween("fadeArrow", 1, TIMING.fadeIn, 0, "linear");
      });
      return;
    }
    const controls = animate(0, 1, {
      ...TIMING.swing,
      onUpdate: (value) => {
        v.swing = value;
      },
      onComplete: () => {
        v.swing = 1;
        swingFrom = null;
        if (runs.swing?.controls === controls) delete runs.swing;
      },
    });
    runs.swing = { controls, to: 1, end: Infinity };
    poke(0);
  };

  const redescribe = () => {
    const want = o && o.describe && o.show ? target : null;
    if (want === described) return;
    if (described) removeToken(described, "aria-describedby", id);
    described = want;
    if (want) addToken(want, "aria-describedby", id);
  };

  const resolve = (): Element | null => {
    const to = o?.to;
    if (!to) return null;
    if (typeof to !== "string") return to.current;
    const scope = els.root.closest("[data-jot-scope]") ?? document;
    try {
      return scope.querySelector(to);
    } catch {
      return null;
    }
  };

  const read = () => {
    if (!o) return;
    const { root, turner, words } = els;
    const rootRect = root.getBoundingClientRect();
    if (!rootRect.width && !rootRect.height) return;
    // A scaled ancestor shrinks every rect alike; dividing it out keeps the arrow in the note's own px.
    const scale = root.offsetWidth ? rootRect.width / root.offsetWidth || 1 : 1;
    if (linesDirty) {
      lines = measureLines(words, turner, scale);
      linesDirty = false;
    }
    width = words.offsetWidth;

    const next = resolve();
    const hadRoute = !!live;
    if (next !== target) {
      if (target && next) startSwing();
      target = next;
      redescribe();
    }
    if (target !== watched) {
      if (watched) resize.unobserve(watched);
      watched = target;
      if (watched) resize.observe(watched);
    }
    let route: Route | null = null;
    const tr = target?.isConnected ? target.getBoundingClientRect() : null;
    if (tr && (tr.width || tr.height)) {
      const local = (r: DOMRect): Box => ({
        x: (r.left - rootRect.left) / scale,
        y: (r.top - rootRect.top) / scale,
        w: r.width / scale,
        h: r.height / scale,
      });
      route = plan(local(turner.getBoundingClientRect()), local(tr), o);
    }
    live = route;
    // The target turned up, went away or came back: draw the arrow or rub it out.
    if (hadRoute !== !!route) sync();
  };

  const paint = (force = false) => {
    if (!o) return;
    const { words, svg, shaft, barbs } = els;
    let route = live ?? drawn;
    if (live && swingFrom) route = lerpRoute(swingFrom, live, v.swing);
    const head1 = handEase(clamp((v.head * HEAD) / TIMING.stroke, 0, 1));
    const head2 = handEase(clamp((v.head * HEAD - TIMING.stagger) / TIMING.stroke, 0, 1));
    // Exact values: a rounded key would hide the last step to 1, and the words would stop a hair short.
    const key = [v.words, v.shaft, head1, head2, v.ghost, v.fadeWords, v.fadeArrow, o.loop, lines?.list.length].join();
    if (!force && key === painted && sameRoute(route, drawn)) return;
    painted = key;

    words.style.clipPath = wipe(lines, v.words);
    words.style.opacity = v.fadeWords < 1 ? String(v.fadeWords) : "";
    if (ghost) {
      ghost.el.style.clipPath = wipe(ghost.lines, v.ghost);
      ghost.el.style.visibility = v.ghost > 0 ? "visible" : "hidden";
    }

    svg.style.opacity = v.fadeArrow < 1 ? String(v.fadeArrow) : "";
    if (!route) {
      for (const p of [shaft, ...barbs]) p.style.visibility = "hidden";
      return;
    }
    drawn = route;
    const line = trace(route, o.loop, o.bend, shake);
    shaft.setAttribute("d", line.d);
    barbs[0].setAttribute("d", barb(line.tip, line.heading, JOT.barbs[0], JOT.spread[0]));
    barbs[1].setAttribute("d", barb(line.tip, line.heading, JOT.barbs[1], -JOT.spread[1]));
    const dash = (p: SVGPathElement, progress: number) => {
      // A zero-length dash still paints a round cap, so an undrawn stroke is hidden outright.
      p.style.visibility = progress > 0.001 ? "visible" : "hidden";
      p.style.strokeDasharray = progress >= 1 ? "" : `${progress} 1`;
    };
    dash(shaft, v.shaft);
    dash(barbs[0], head1);
    dash(barbs[1], head2);
  };

  const loop = (on: boolean) => {
    if (on === looping) return;
    looping = on;
    if (on) {
      frame.read(read, true);
      frame.render(render, true);
    } else {
      cancelFrame(read);
      cancelFrame(render);
    }
  };

  /** Read and paint every frame for a while, because something may be moving. */
  const poke = (ms: number = TIMING.watch) => {
    until = Math.max(until, performance.now() + ms);
    if (near) loop(true);
  };

  const render = () => {
    paint();
    const t = performance.now();
    moving.forEach((props, el) => {
      props.forEach((at, name) => {
        if (t - at > TIMING.stale) props.delete(name);
      });
      if (!props.size) moving.delete(el);
    });
    // Settled: nothing of ours is animating, nothing in the scope is moving, nothing stirred lately.
    if (!Object.keys(runs).length && !moving.size && t > until) loop(false);
  };

  const arm = () => {
    if (!o || triggered) return;
    if (o.trigger === "mount") {
      triggered = true;
      sync();
      return;
    }
    const view = new IntersectionObserver(
      ([entry]) => {
        if (!entry?.isIntersecting) return;
        view.disconnect();
        triggered = true;
        sync();
      },
      { threshold: JOT.view },
    );
    view.observe(els.root);
    observers.push(view);
  };

  // Near the screen it watches for anything that could move a box; off screen it does nothing.
  const scope = els.root.closest("[data-jot-scope]") ?? document.body;
  const nearby = new IntersectionObserver(
    ([entry]) => {
      near = !!entry?.isIntersecting;
      if (near) poke();
      else loop(false);
    },
    { rootMargin: "200px" },
  );
  nearby.observe(els.root);
  const resize = new ResizeObserver((entries) => {
    if (entries.some((e) => e.target === els.words)) linesDirty = true;
    poke();
  });
  resize.observe(els.words);
  resize.observe(els.root);
  if (els.root.parentElement) resize.observe(els.root.parentElement);
  // Our own paint writes styles inside the note; only changes elsewhere count.
  const mutations = new MutationObserver((records) => {
    if (records.some((r) => !els.root.contains(r.target))) poke(TIMING.watch / 2);
  });
  // Every attribute, so a selector like `[data-active]` finds its new match the moment it moves.
  mutations.observe(scope, { subtree: true, childList: true, characterData: true, attributes: true });
  observers.push(nearby, resize, mutations);
  const stir = (e: Event) => {
    const el = e.target;
    const relevant =
      !(el instanceof Node) || scope.contains(el) || el.contains(els.root) || (!!target && el.contains(target));
    if (!el || !relevant) return;
    if (e instanceof TransitionEvent || e instanceof AnimationEvent) {
      const name = e instanceof TransitionEvent ? e.propertyName : e.animationName;
      const props = moving.get(el) ?? new Map<string, number>();
      if (e.type === "transitionend" || e.type === "transitioncancel" || e.type === "animationend" || e.type === "animationcancel") {
        props.delete(name);
      } else props.set(name, performance.now());
      if (props.size) moving.set(el, props);
      else moving.delete(el);
    }
    poke(TIMING.tail);
  };
  const events = [
    "scroll",
    "transitionrun",
    "transitionend",
    "transitioncancel",
    "animationstart",
    "animationiteration",
    "animationend",
    "animationcancel",
  ];
  events.forEach((type) => document.addEventListener(type, stir, { capture: true, passive: true }));
  const onResize = () => poke();
  window.addEventListener("resize", onResize, { passive: true });
  document.fonts?.ready.then(() => {
    linesDirty = true;
    poke();
  });

  return {
    set(next: PenOptions) {
      const prev = o;
      o = next;
      poke();
      if (!prev || prev.text !== next.text) shake = tremor(next.text);
      if (!prev || prev.trigger !== next.trigger) arm();
      if (prev && (prev.from !== next.from || prev.at !== next.at || prev.bend !== next.bend || prev.gap !== next.gap)) {
        startSwing();
      }
      if (!prev || prev.show !== next.show || prev.reduced !== next.reduced) sync();
      if (!prev || prev.describe !== next.describe || prev.show !== next.show) redescribe();
    },

    /** New words: the old ones wipe out from a copy, then the new ones write in. */
    rewrite(el: HTMLElement) {
      if (!o || v.words <= 0) return;
      const { root } = els;
      const scale = root.offsetWidth ? root.getBoundingClientRect().width / root.offsetWidth || 1 : 1;
      el.style.width = width ? `${width}px` : "max-content";
      ghost = { el, lines: measureLines(el, els.turner, scale) };
      linesDirty = true;
      stop("words");
      v.ghost = v.words;
      v.words = 0;
      const writing = triggered && o.show;
      if (o.reduced) {
        v.ghost = 0;
        v.words = writing ? 1 : 0;
      } else {
        const out = TIMING.unwrite * v.ghost * pace();
        tween("ghost", 0, out, 0, "linear");
        if (writing) tween("words", 1, writeTime(), out);
      }
      startSwing();
      paint(true);
    },

    destroy() {
      loop(false);
      observers.forEach((ob) => ob.disconnect());
      events.forEach((type) => document.removeEventListener(type, stir, { capture: true }));
      window.removeEventListener("resize", onResize);
      (Object.keys(runs) as Key[]).forEach(stop);
      if (described) removeToken(described, "aria-describedby", id);
      described = null;
      o = null;
    },
  };
}

type Pen = ReturnType<typeof createPen>;

/* ── Jot ──────────────────────────────────────────────── */

export interface JotProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The words. Keep them short: a few words on one or two lines. */
  children: React.ReactNode;
  /**
   * What the arrow points at: a ref, or a CSS selector looked up inside the nearest
   * `[data-jot-scope]` ancestor (the whole document without one). Leave it out for a note
   * with no arrow.
   */
  to?: React.RefObject<Element | null> | string;
  /** The side of the note the arrow leaves from. `"auto"` picks the side facing the target. */
  from?: Side;
  /** The side of the target the arrow lands on. `"auto"` aims at its center and stops at its edge. */
  at?: Side;
  /** How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way. */
  bend?: number;
  /** Curl the arrow once midway, like a quick flourish. */
  loop?: boolean;
  /** Pixels left between the arrowhead and the target. */
  gap?: number;
  /** Degrees to turn the words, for a looser hand. The arrow isn't turned. */
  tilt?: number;
  /** When it writes itself: once half of it is on screen, or as soon as it mounts. */
  trigger?: "view" | "mount";
  /** Seconds to wait before the first write, to write several notes one after another. */
  delay?: number;
  /** How fast the pen writes, draws and rubs out, as a multiple of the default pace. `2` is twice as fast. */
  speed?: number;
  /** Set to false to rub the note out; true writes it back in. */
  show?: boolean;
  /** Describe the target with the note's words for screen readers, through `aria-describedby`. */
  describe?: boolean;
}

export function Jot({
  children,
  to,
  from = "auto",
  at = "auto",
  bend = 0.35,
  loop = false,
  gap = 6,
  tilt = 0,
  trigger = "view",
  delay = TIMING.delay,
  speed = TIMING.speed,
  show = true,
  describe = true,
  className,
  ...props
}: JotProps) {
  const reduced = useReducedMotion() ?? false;
  const id = React.useId();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const turnerRef = React.useRef<HTMLSpanElement>(null);
  const wordsRef = React.useRef<HTMLSpanElement>(null);
  const ghostRef = React.useRef<HTMLSpanElement>(null);
  const svgRef = React.useRef<SVGSVGElement>(null);
  const shaftRef = React.useRef<SVGPathElement>(null);
  const barbARef = React.useRef<SVGPathElement>(null);
  const barbBRef = React.useRef<SVGPathElement>(null);
  const penRef = React.useRef<Pen | null>(null);

  // New words keep the old ones on screen as a copy, so they can wipe out before the new ones write.
  const text = textOf(children);
  const [kept, setKept] = React.useState({ text, children });
  const [ghost, setGhost] = React.useState<{ key: number; children: React.ReactNode } | null>(null);
  if (kept.text !== text) {
    setGhost({ key: (ghost?.key ?? 0) + 1, children: kept.children });
    setKept({ text, children });
  }

  React.useLayoutEffect(() => {
    const [root, turner, words, svg, shaft, a, b] = [
      rootRef.current,
      turnerRef.current,
      wordsRef.current,
      svgRef.current,
      shaftRef.current,
      barbARef.current,
      barbBRef.current,
    ];
    if (!root || !turner || !words || !svg || !shaft || !a || !b) return;
    const pen = createPen({ root, turner, words, svg, shaft, barbs: [a, b] }, id);
    penRef.current = pen;
    return () => {
      pen.destroy();
      penRef.current = null;
    };
  }, [id]);

  React.useLayoutEffect(() => {
    penRef.current?.set({ to, from, at, bend, loop, gap, trigger, delay, speed, show, describe, reduced, text });
  });

  React.useLayoutEffect(() => {
    if (ghost && ghostRef.current) penRef.current?.rewrite(ghostRef.current);
  }, [ghost]);

  return (
    <div
      ref={rootRef}
      data-jot=""
      aria-hidden={show ? undefined : true}
      className={cn(
        "pointer-events-none relative z-10 w-max max-w-56 font-['Segoe_Print','Bradley_Hand',Chilanka,casual,cursive] text-lg/snug select-none",
        className,
      )}
      {...props}
    >
      <span ref={turnerRef} className="relative block" style={tilt ? { rotate: `${tilt}deg` } : undefined}>
        <span ref={wordsRef} id={id} className="block" style={{ clipPath: HIDDEN }}>
          {children}
        </span>
        {ghost ? (
          <span
            key={ghost.key}
            ref={ghostRef}
            aria-hidden
            className="absolute top-0 left-0 block"
            style={{ clipPath: HIDDEN, visibility: "hidden" }}
          >
            {ghost.children}
          </span>
        ) : null}
      </span>
      <svg
        ref={svgRef}
        aria-hidden
        className="pointer-events-none absolute top-0 left-0 size-px overflow-visible"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.75}
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path ref={shaftRef} pathLength={1} style={{ visibility: "hidden" }} />
        <path ref={barbARef} pathLength={1} style={{ visibility: "hidden" }} />
        <path ref={barbBRef} pathLength={1} style={{ visibility: "hidden" }} />
      </svg>
    </div>
  );
}
```
