# Footnote: AI Answer With Citations for React

> An AI answer that shows its work. It reads its sources, dealing a chip for each one, then writes the answer, and as each claim lands its source's number lifts out of the chip and drops in at the end of the sentence. Point at a sentence and its source lights up; point at a source and the sentences it backs light up, and resting on it opens the passage the answer leans on.

AI Answer With Citations for React: each claim's source drops in as the answer writes. 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/footnote
- Tags: AI, Text, Hover
- npm dependencies: motion, clsx, tailwind-merge

## 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/footnote.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

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

Or install by URL, without registering the namespace:

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

## Usage

The main demo shows typical usage.

### Help Center

A workspace app's help assistant answers a data-residency question from its docs, status page, trust center and changelog. A community thread it read but didn't cite sits last, with a monogram.

Install this demo, with the component included:

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

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

import { Footnote, type FootnoteClaim, type FootnoteSource } from "@/components/ui/footnote";

const sources: FootnoteSource[] = [
  {
    id: "docs",
    site: "docs.plinth.so",
    title: "Data residency: moving a workspace",
    href: "#data-residency",
    snippet:
      "Owners of Business and Enterprise workspaces can move their data to our Frankfurt region. Start the move from Settings → Data residency and pick a window that suits your team.",
    quote: "move their data to our Frankfurt region",
  },
  {
    id: "status",
    site: "status.plinth.so",
    title: "Scheduled maintenance: region moves",
    href: "#region-moves",
    snippet:
      "While a move runs, the workspace stays readable but can't be edited. Plan for about 20 minutes for every 10 GB of files.",
    quote: "about 20 minutes for every 10 GB of files",
  },
  {
    id: "trust",
    site: "trust.plinth.so",
    title: "Audit log retention",
    href: "#audit-logs",
    snippet:
      "When a workspace changes region, audit events from the last 90 days move with it. Older events stay where they were recorded until they expire.",
    quote: "Older events stay where they were recorded",
  },
  {
    id: "changelog",
    site: "plinth.so/changelog",
    title: "5.2: EU data residency is generally available",
    href: "#changelog-5-2",
    snippet: "After six months in beta, hosting in Frankfurt is open to every Business and Enterprise workspace.",
    quote: "open to every Business and Enterprise workspace",
  },
  {
    id: "community",
    site: "community.plinth.so",
    title: "Moving a 40 GB workspace: how long did yours take?",
    href: "#community-thread",
    snippet: "Ours took a little under an hour on a Sunday morning. Search was back before the move finished.",
  },
];

const answer: FootnoteClaim[] = [
  {
    text: "Yes. Workspaces on the Business and Enterprise plans can move to Frankfurt from Settings → Data residency.",
    cite: "docs",
  },
  {
    text: "The move runs in a window you pick, and the workspace is read-only for about 20 minutes per 10 GB of files.",
    cite: "status",
  },
  {
    text: "Pages, files and search move with it; audit logs older than 90 days stay in the US until they expire.",
    cite: "trust",
  },
  { text: "EU hosting has been generally available since version 5.2.", cite: "changelog" },
];

export function FootnoteDemo() {
  return (
    <Footnote
      question="Can we move our workspace to the EU region?"
      answer={answer}
      sources={sources}
      read={11}
      avatar={<PlinthMark className="size-3.5" />}
      className="max-w-lg"
    />
  );
}

function PlinthMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden>
      <path d="M2 12.5h12V14H2zM4 8.5h8V11H4zM6 3h4v4H6z" fill="currentColor" />
    </svg>
  );
}
```

## Examples

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

### Glass Over a Photo

A trip planner's answer on a frosted card over Lisbon at dusk, in a browser frame: where to stay for two nights on foot, with walking, review, weather and transit sources.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/footnote-glass-demo
```

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

import { Footnote, type FootnoteClaim, type FootnoteSource } from "@/components/ui/footnote";

const PHOTO =
  "https://images.unsplash.com/photo-1688297483354-3057315926a1?auto=format&fit=crop&w=1600&h=1100&q=80";

const sources: FootnoteSource[] = [
  {
    id: "walks",
    site: "lisbonwalks.guide",
    title: "Baixa on foot: the flat way round",
    href: "#baixa-on-foot",
    snippet:
      "Baixa is the low, level grid rebuilt after 1755. From Praça do Comércio most of the old town is within 20 minutes' walk, and you only climb when you choose to.",
    quote: "within 20 minutes' walk",
  },
  {
    id: "stays",
    site: "staynotes.app",
    title: "Alfama guesthouses: what 2,400 reviews mention",
    href: "#alfama-reviews",
    snippet:
      "Views and quiet come up most, then stairs. Guests with suitcases call the walk up from the metro the hardest part of the stay.",
    quote: "the hardest part of the stay",
  },
  {
    id: "climate",
    site: "climatenormals.org",
    title: "Lisbon: November averages",
    href: "#november-averages",
    snippet: "Daytime highs average 17°C. Rain falls on about ten days of the month, usually in short showers.",
    quote: "about ten days of the month",
  },
  {
    id: "transit",
    site: "transitatlas.io",
    title: "Tram 28: when it's quiet",
    href: "#tram-28",
    snippet: "Cars fill from about 10am until early evening. Before 9am you'll usually get a seat at Martim Moniz.",
    quote: "Before 9am you'll usually get a seat",
  },
];

const answer: FootnoteClaim[] = [
  {
    text: "Stay in Baixa: it's the flat grid between the river and the hills, and most sights are within 20 minutes on foot.",
    cite: "walks",
  },
  { text: "Alfama is prettier but steep, and with luggage the climb from the metro is the hard part.", cite: "stays" },
  { text: "November is mild, around 17°C, but expect rain on about one day in three.", cite: "climate" },
  { text: "Tram 28 is packed by 10am, so ride it early or walk the same route downhill.", cite: "transit" },
];

export function FootnoteGlassDemo() {
  return (
    <div className="@container/frame relative w-full max-w-3xl overflow-hidden rounded-[22px] bg-zinc-900 shadow-xl ring-1 ring-zinc-950/10 dark:ring-white/10">
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={PHOTO}
        alt="Lisbon's rooftops at dusk under a deep blue and orange sky"
        className="absolute inset-0 size-full object-cover"
      />
      <div className="absolute inset-0 bg-gradient-to-t from-zinc-950/35 via-transparent to-zinc-950/10" />

      <div className="relative flex h-10 items-center gap-3 border-b border-white/15 bg-white/10 px-3.5 backdrop-blur-md">
        <div className="flex gap-1.5" aria-hidden>
          <span className="size-2.5 rounded-full bg-white/45" />
          <span className="size-2.5 rounded-full bg-white/30" />
          <span className="size-2.5 rounded-full bg-white/30" />
        </div>
        <div className="mx-auto flex h-6 w-full max-w-64 items-center justify-center gap-1.5 rounded-full bg-white/15 px-3 text-[11px] text-white/85">
          <LockIcon className="size-2.5 shrink-0" />
          <span className="truncate">saunter.app/ask</span>
        </div>
        <div className="w-10" aria-hidden />
      </div>

      <div className="relative px-3 pt-40 pb-3 @[34rem]/frame:px-8 @[34rem]/frame:pt-52 @[34rem]/frame:pb-8">
        <Footnote
          question="Two nights in Lisbon in November: where should we stay to walk everywhere?"
          answer={answer}
          sources={sources}
          read={14}
          avatar={<SunMark className="size-3.5" />}
          className="max-w-[27rem] bg-white/70 shadow-2xl shadow-zinc-950/20 ring-white/60 backdrop-blur-xl dark:bg-zinc-950/60 dark:ring-white/15"
        />
      </div>
    </div>
  );
}

function SunMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden>
      <path d="M2 11.5a6 6 0 0 1 12 0Z" fill="currentColor" />
      <path d="M1.5 13.25h13" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
    </svg>
  );
}

function LockIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
      <rect x="2.5" y="5.25" width="7" height="5" rx="1.25" fill="currentColor" />
      <path d="M4 5.25V4a2 2 0 1 1 4 0v1.25" stroke="currentColor" strokeWidth={1.2} />
    </svg>
  );
}
```

### Follow-up Questions

A company handbook assistant with follow-up questions under the card. Pick one and the answer folds away, the card eases to its new height and the next answer reads and writes itself. Its status line says "Checked 7 handbook pages".

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/footnote-follow-ups-demo
```

```tsx title="components/footnote-follow-ups-demo.tsx"
"use client";

import * as React from "react";

import { Footnote, type FootnoteClaim, type FootnoteSource } from "@/components/ui/footnote";

const sources: Record<string, FootnoteSource> = {
  abroad: {
    id: "abroad",
    site: "handbook.kiln.team",
    title: "Working from abroad",
    href: "#working-from-abroad",
    snippet:
      "Everyone can work from another country for up to 30 working days per calendar year, taken in blocks of no more than 10 consecutive working days.",
    quote: "up to 30 working days per calendar year",
  },
  requests: {
    id: "requests",
    site: "hr.kiln.team",
    title: "Requesting time abroad",
    href: "#requesting-time-abroad",
    snippet:
      "Submit the request in Kiln HR at least 14 days before you travel. Your lead approves it, and payroll and People Ops are told automatically.",
    quote: "at least 14 days before you travel",
  },
  offices: {
    id: "offices",
    site: "kiln.team/offices",
    title: "Offices and hubs",
    href: "#offices",
    snippet: "We have offices in Berlin, Lisbon and Toronto. Days spent working from any Kiln office count as office days.",
    quote: "count as office days",
  },
  excluded: {
    id: "excluded",
    site: "legal.kiln.team",
    title: "Countries we can't support",
    href: "#excluded-countries",
    snippet:
      "For tax and export reasons we can't approve work from sanctioned countries or from countries without a tax treaty with your employing entity.",
    quote: "without a tax treaty",
  },
  payroll: {
    id: "payroll",
    site: "payroll.kiln.team",
    title: "Payroll FAQ: working abroad",
    href: "#payroll-faq",
    snippet:
      "If a block runs past 10 days or crosses into a new tax year, we'll get in touch within two working days to check your withholding.",
    quote: "within two working days",
  },
  travel: {
    id: "travel",
    site: "handbook.kiln.team/travel",
    title: "Travel and expenses",
    href: "#travel-policy",
    snippet: "Per diems apply to trips your team asks you to make. Time abroad you choose follows the normal expense rules.",
    quote: "Per diems apply to trips your team asks you to make",
  },
  spaces: {
    id: "spaces",
    site: "spaces.kiln.team",
    title: "Booking a desk in Lisbon",
    href: "#lisbon-desks",
    snippet: "Lisbon has 24 bookable desks. Book at least a day ahead; Tuesdays and Wednesdays fill first.",
    quote: "Tuesdays and Wednesdays fill first",
  },
};

type Turn = { question: string; answer: FootnoteClaim[]; sources: FootnoteSource[]; read: number };

const turns: Turn[] = [
  {
    question: "How many days can I work from abroad each year?",
    answer: [
      { text: "Up to 30 working days a year, in blocks of at most 10 days in a row.", cite: "abroad" },
      { text: "Each block needs your lead's approval in Kiln HR two weeks before you go.", cite: "requests" },
      { text: "Days at a Kiln office in another country don't count toward the 30.", cite: ["abroad", "offices"] },
      { text: "Sanctioned countries, and any without a tax treaty, can't be approved.", cite: "excluded" },
    ],
    sources: [sources.abroad, sources.requests, sources.offices, sources.excluded],
    read: 7,
  },
  {
    question: "Do I need to tell payroll first?",
    answer: [
      { text: "No. Once your lead approves the request, payroll gets the dates automatically.", cite: "requests" },
      {
        text: "If a block runs past 10 days or into a new tax year, payroll will check your withholding within two working days.",
        cite: "payroll",
      },
      { text: "Per diems don't apply, since it's time abroad you chose rather than a work trip.", cite: "travel" },
    ],
    sources: [sources.requests, sources.payroll, sources.travel],
    read: 5,
  },
  {
    question: "Does the Lisbon office count as abroad?",
    answer: [
      { text: "No. Days working from the Lisbon office count as office days, not days abroad.", cite: "offices" },
      { text: "Book a desk at least a day ahead: there are 24, and Tuesdays fill first.", cite: "spaces" },
      {
        text: "For stays longer than a week, file a request anyway so the visit shows on your team's calendar.",
        cite: "requests",
      },
    ],
    sources: [sources.offices, sources.spaces, sources.requests],
    read: 4,
  },
];

export function FootnoteFollowUpsDemo() {
  const [current, setCurrent] = React.useState(0);
  const turn = turns[current];
  const next = turns.map((t, i) => ({ ...t, i })).filter((t) => t.i !== current);

  return (
    <div className="flex w-full max-w-lg flex-col gap-3">
      <Footnote
        question={turn.question}
        answer={turn.answer}
        sources={turn.sources}
        read={turn.read}
        avatar={<KilnMark className="size-3.5" />}
        status={(read, done) => (done ? `Checked ${read} handbook pages` : `Checking the handbook · ${read}`)}
        className="[--footnote-accent:#c2410c] dark:[--footnote-accent:#fb923c]"
      />
      <div className="flex flex-wrap items-center gap-1.5 pl-1">
        <span className="mr-1 text-xs text-zinc-500 dark:text-zinc-400">Ask next</span>
        {next.map((t) => (
          <button
            key={t.i}
            type="button"
            onClick={() => setCurrent(t.i)}
            className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-white px-3 text-xs text-zinc-700 shadow-sm ring-1 ring-zinc-950/10 transition-[background-color,color,scale] duration-150 ease-out hover:bg-zinc-50 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 active:scale-[0.97] dark:bg-zinc-900 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-800 dark:hover:text-white dark:focus-visible:outline-white"
          >
            <ReplyIcon className="size-3 text-zinc-400" />
            {t.question}
          </button>
        ))}
      </div>
    </div>
  );
}

function KilnMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden>
      <path d="M3 14V7.5a5 5 0 0 1 10 0V14h-3V9.5a2 2 0 0 0-4 0V14Z" fill="currentColor" />
    </svg>
  );
}

function ReplyIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
      <path
        d="M2.5 2.5v3a2 2 0 0 0 2 2h5m0 0L7.5 5.5m2 2-2 2"
        stroke="currentColor"
        strokeWidth={1.3}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `question` (required) | `string` |  | The question, at the top of the card. A new question folds the old answer away and plays the new one. |
| `answer` (required) | `FootnoteClaim[]` |  | The answer, claim by claim, in reading order: `{ text, cite? }`, where `cite` is a source `id` or several. Each claim gets a marker for every source it cites. |
| `sources` (required) | `FootnoteSource[]` |  | The sources it read: `{ id, site, title, href?, snippet?, quote? }`. Cited ones are numbered in the order the answer first cites them; the rest follow with a monogram. `snippet` is the passage shown when a source opens, with `quote` marked in it. |
| `read` | `number` |  | How many sources it says it read, when that's more than you list. The extra ones show as "+N more". |
| `play` | `boolean \| "view"` | `"view"` | When it plays: `"view"` once a third of it is on screen, `true` as soon as it mounts, `false` never (the finished answer shows). |
| `speed` | `number` | `45` | How fast the answer writes itself, in words a second. |
| `arc` | `number` | `26` | Pixels each source's number arcs up over a straight line as it flies to its claim. |
| `flightDuration` | `number` | `0.45` | Seconds a number takes to fly from its source's chip into the claim's marker. |
| `flightBounce` | `number` | `0.16` | How much a number overshoots its marker before it settles, from `0` (none) up. |
| `avatar` | `React.ReactNode` |  | The badge beside the question, like your product's mark. A small spark when unset. |
| `status` | `(read: number, done: boolean) => React.ReactNode` | `(read) => `Read ${read} sources`` | The status line's text, from the number of sources read so far and whether reading has finished. |
| `onComplete` | `() => void` |  | Called when an answer has finished: written, every number landed. At once when it doesn't play. |
| `className` | `string` |  | Classes for the card. Override its fill, ring and blur for a glass card over imagery (`bg-white/70 backdrop-blur-xl ring-white/60`), and set the accent with `[--footnote-accent:#0f766e]`. |

## What to keep

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

- Every word, marker and chip is in the markup from the start and only shown as the run reaches it, so playing never changes the card's height.
- The run is planned up front in ms (`buildPlan`), and one timer jumps a clock kept outside React from event to event; off screen or in a hidden tab it stops and picks up where it was.
- Writing never waits for the numbers. A flying number re-measures its marker every frame and hands off to it in the frame it lands, and a slower flight pushes the finish back to match.
- A link holds for a moment between claims, so moving across lines doesn't flicker; a source opens after the hover intent and closes only once the pointer has left both chip and card.
- Reduced motion, read only after hydration, shows the finished answer at once with no dealing, streaming or flights. Links and source cards still work, and cards fade without growing.

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/footnote.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, animate, motion, useInView, useReducedMotion } from "motion/react";

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

/**
 * Keep:
 * - Every word, marker and chip is in the markup from the start and only shown as the run reaches
 *   it, so playing never changes the card's height.
 * - The run is planned up front in ms (`buildPlan`), and one timer jumps a clock kept outside React
 *   from event to event; off screen or in a hidden tab it stops and picks up where it was.
 * - Writing never waits for the numbers. A flying number re-measures its marker every frame and
 *   hands off to it in the frame it lands, and a slower flight pushes the finish back to match.
 * - A link holds for a moment between claims, so moving across lines doesn't flicker; a source opens
 *   after the hover intent and closes only once the pointer has left both chip and card.
 * - Reduced motion, read only after hydration, shows the finished answer at once with no dealing,
 *   streaming or flights. Links and source cards still work, and cards fade without growing.
 */
const TIMING = {
  /** After play, before the first source is read, in ms: the status line arrives with it. */
  start: 0,
  /** What the reading aims to take, whatever the count, in ms. */
  spread: 900,
  /** Between reads, at least and at most, in ms. */
  step: [35, 90],
  /** Between the last read and the first word, in ms. */
  hold: 240,
  /** How fast the answer writes itself, in words a second (the `speed` prop). */
  speed: 45,
  /** A word fading in, in seconds; the run waits for the last one. */
  word: 0.12,
  /** The same fade, as Tailwind classes. Keep it equal to `word`. */
  wordFade: "duration-[120ms] ease-out",
  /** After a claim's last word, before its number lifts off, in ms. */
  lag: 60,
  /** Between the numbers of one claim, in ms. */
  stagger: 70,
  /** From the last lift-off until the run counts as done, at the default flight, in ms. */
  settle: 520,
  /** A number flying into its marker (the `flightDuration` and `flightBounce` props). */
  fly: { type: "spring", visualDuration: 0.45, bounce: 0.16, restDelta: 0.002, restSpeed: 0.02 },
  /** The ring on a chip as its number lifts off. */
  pulse: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
  /** The question and the status line rising in. */
  rise: { duration: 0.3, ease: [0.22, 1, 0.36, 1] },
  /** The old answer leaving for good. */
  fold: { duration: 0.2, ease: [0.55, 0, 0.75, 0.4] },
  /** The card easing to the new answer's height. */
  height: { type: "spring", visualDuration: 0.34, bounce: 0 },
  /** A link holding before it clears, in ms. */
  linger: 100,
  /** On a chip before its source opens, in ms. */
  intent: 120,
  /** After leaving chip and card, before the source closes, in ms. */
  leave: 250,
  /** A source card growing out of its chip. */
  open: { type: "spring", visualDuration: 0.3, bounce: 0.1 },
  /** A source card closing, and its fade in and out under reduced motion. */
  close: { duration: 0.15, ease: "easeOut" },
  /** A claim lighting up or dimming (`on`) and clearing (`off`), as Tailwind classes. */
  link: { ease: "ease-out", on: "duration-75", off: "duration-150" },
  /** A chip lighting up or dimming (`on`), and being dealt or clearing (`off`), as Tailwind classes. */
  chip: { ease: "ease-out", on: "duration-75", off: "duration-200" },
  /** The "+N more" chip being dealt, as Tailwind classes. */
  more: "duration-200 ease-out",
  /** Markers, chips and their numbers changing tint, as a Tailwind class. */
  tint: "duration-150",
  /** The footer's rule showing with the first read, as a Tailwind class. */
  rule: "duration-200",
  /** The placeholder lines fading as the first word lands, as Tailwind classes. */
  skeleton: "duration-200 ease-out",
} as const;

/* ─────────────────────────────────────────────────────────
 * FOOTNOTE STORYBOARD
 *
 *  rest     the question with a small badge, three faint lines
 *           where the answer will be, and an empty footer. Every
 *           word is already in the page, hidden: playing only shows
 *           them, so the card never changes height. Without play
 *           (or with reduced motion) the finished answer shows
 *  view     a third of it in view: the status line rises 6px with
 *           a spinner and "Read 1 source". Sources are read one at
 *           a time, 35–90ms apart (about 0.9s in all): each deals
 *           its chip into the footer (0.96 → 1 and a fade, 0.2s)
 *           and ticks the count. Reads past the last chip only
 *           tick "+N more"
 *  write    0.24s after the last read the faint lines fade and the
 *           answer streams in at 45 words a second, each word
 *           fading in over 0.12s
 *  cite     60ms after a cited claim's last word lands, the number
 *           in its source's chip lights up and lifts out: a ring
 *           pulses on the chip (0.5s) while the number flies up in
 *           an arc that peaks 26px over the straight line, grows
 *           22% at the top and settles into the claim's marker on
 *           a 0.45s spring with a little give. Two sources on one
 *           claim leave 70ms apart. Writing never waits for them
 *  done     the spinner turns into a check
 *  link     point at a claim or its marker: the claim takes an
 *           accent tint, its chips rise 2px and ring in the accent,
 *           and every other claim fades to 45%. Point at a chip and
 *           the claims it backs light up instead. On in 75ms, off
 *           in 150ms, and it holds 100ms between claims so moving
 *           across lines doesn't flicker
 *  preview  rest on a chip for 120ms (or focus it, or tap it) and
 *           a source card grows out of it (0.92 → 1, 0.3s spring,
 *           a touch of give): the site, the title and the passage
 *           with the quoted part marked. It opens above the chip
 *           when there's room and below when there isn't, and
 *           closes 250ms after the pointer leaves chip and card
 *  again    a new question folds the old answer up and out as one
 *           block (4px, 0.2s), the card eases to its new height
 *           and the new question rises in and plays from reading
 *  touch    tap a claim to link it, tap a chip or a marker to open
 *           its source, tap outside to clear
 *  keys     Tab walks the markers in reading order, then the
 *           chips. Enter on a marker moves focus to its chip and
 *           opens its source; focusing a chip opens it too, and
 *           Space toggles it. Esc closes and keeps focus on the chip
 *  sleep    off screen or in a hidden tab the clock stops and
 *           picks up where it was
 *  reduced  no dealing, streaming or flights: the finished answer
 *           shows at once. Links and source cards still work, and
 *           cards fade in without growing
 * ───────────────────────────────────────────────────────── */

const NOTE = {
  arc: 26, // px the flight peaks over a straight line
  grow: 0.22, // extra scale at the top of the arc
  lift: 6, // px the question and status rise in from
  gap: 8, // px between a chip and its open source
} as const;

const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
const smoothstep = (a: number, b: number, v: number) => {
  const x = clamp((v - a) / (b - a), 0, 1);
  return x * x * (3 - 2 * x);
};
const monogram = (site: string) =>
  site
    .replace(/^(https?:\/\/)?(www\.)?/, "")
    .charAt(0)
    .toUpperCase();
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const SKELETON = ["94%", "100%", "58%"]; // widths of the lines held for the answer while it reads

/* ── Types ──────────────────────────────────────────────── */

export interface FootnoteSource {
  /** What a claim's `cite` points at. */
  id: string;
  /** The site's name or domain, shown on the chip: "docs.plinth.so". */
  site: string;
  /** The page's title, shown when the source opens. */
  title: string;
  /** Where the source lives. The chip and the open card's title link to it. */
  href?: string;
  /** The passage the answer leans on, shown when the source opens. */
  snippet?: string;
  /** The words of `snippet` to mark, when they appear in it. */
  quote?: string;
}

export interface FootnoteClaim {
  /** A sentence or a clause of the answer. */
  text: string;
  /** The `id` of the source that backs it, or several. Leave it out for a claim with no source. */
  cite?: string | string[];
}

export interface FootnoteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The question, at the top of the card. A new question folds the old answer away and plays the new one. */
  question: string;
  /** The answer, claim by claim, in reading order. Each claim gets a marker for every source it cites. */
  answer: FootnoteClaim[];
  /** The sources it read. Cited ones are numbered in the order the answer first cites them; the rest follow with a monogram. */
  sources: FootnoteSource[];
  /** How many sources it says it read, when that's more than you list. The extra ones show as "+N more". */
  read?: number;
  /** When it plays: `"view"` once a third of it is on screen, `true` as soon as it mounts, `false` never (the finished answer shows). */
  play?: boolean | "view";
  /** How fast the answer writes itself, in words a second. */
  speed?: number;
  /** Pixels each source's number arcs up over a straight line on its way to the claim. */
  arc?: number;
  /** Seconds a number takes to fly from its chip to the claim. */
  flightDuration?: number;
  /** How much a number overshoots its marker and settles, from 0 (none) up. */
  flightBounce?: number;
  /** The badge beside the question, like your product's mark. A small spark when unset. */
  avatar?: React.ReactNode;
  /** The status line's text, from the number of sources read so far and whether reading has finished. */
  status?: (read: number, done: boolean) => React.ReactNode;
  /** Called when an answer has finished: written, every number landed. At once when it doesn't play. */
  onComplete?: () => void;
}

const defaultStatus = (read: number) => `Read ${plural(read, "source")}`;

/* ── Plan ───────────────────────────────────────────────── */

// Everything the run does, as times in ms from the moment it starts playing.
type Mark = { id: string; num: number };
type PlanClaim = { words: string[]; start: number; marks: Mark[] };
type PlanFlight = { claim: number; id: string; num: number; at: number };
type Plan = {
  claims: PlanClaim[];
  order: { source: FootnoteSource; num?: number }[];
  words: number;
  reads: number;
  readAt: number[];
  writeAt: number;
  per: number;
  flights: PlanFlight[];
  doneAt: number;
  events: number[];
};

function buildPlan(
  answer: FootnoteClaim[],
  sources: FootnoteSource[],
  read: number | undefined,
  speed: number,
  settle: number,
): Plan {
  const known = new Set(sources.map((s) => s.id));
  const nums = new Map<string, number>();
  let words = 0;
  const claims = answer.map((claim) => {
    const ids = (Array.isArray(claim.cite) ? claim.cite : claim.cite ? [claim.cite] : []).filter(
      (id, i, all) => known.has(id) && all.indexOf(id) === i,
    );
    const marks = ids.map((id) => {
      if (!nums.has(id)) nums.set(id, nums.size + 1);
      return { id, num: nums.get(id)! };
    });
    const list = claim.text.trim().split(/\s+/).filter(Boolean);
    const planned = { words: list, start: words, marks };
    words += list.length;
    return planned;
  });
  const order = [
    ...sources.filter((s) => nums.has(s.id)).sort((a, b) => nums.get(a.id)! - nums.get(b.id)!),
    ...sources.filter((s) => !nums.has(s.id)),
  ].map((source) => ({ source, num: nums.get(source.id) }));

  const reads = Math.max(read ?? 0, sources.length);
  const step = clamp(TIMING.spread / Math.max(1, reads), TIMING.step[0], TIMING.step[1]);
  const readAt = Array.from({ length: reads }, (_, k) => Math.round(TIMING.start + k * step));
  const writeAt = (readAt[reads - 1] ?? TIMING.start) + TIMING.hold;
  const per = 1000 / Math.max(1, speed);

  const flights: PlanFlight[] = [];
  claims.forEach((claim, i) => {
    if (!claim.words.length) return;
    const landed = writeAt + (claim.start + claim.words.length - 1) * per;
    claim.marks.forEach((mark, m) => {
      flights.push({ claim: i, id: mark.id, num: mark.num, at: Math.round(landed + TIMING.lag + m * TIMING.stagger) });
    });
  });
  const lastWord = writeAt + Math.max(0, words - 1) * per + TIMING.word * 1000;
  const doneAt = Math.round(Math.max(lastWord, ...flights.map((f) => f.at + settle)));

  const events = new Set<number>([...readAt, ...flights.map((f) => f.at), doneAt]);
  for (let g = 0; g < words; g++) events.add(Math.round(writeAt + g * per));
  return {
    claims,
    order,
    words,
    reads,
    readAt,
    writeAt,
    per,
    flights,
    doneAt,
    events: [...events].sort((a, b) => a - b),
  };
}

/* ── Run store ──────────────────────────────────────────── */

// The clock, the numbers in flight and the ones that have landed, for the answer on screen. It
// lives outside React so timers and animation callbacks can move it without effects setting state.
type Point = { x: number; y: number };
type Flight = { key: string; num: number; from: Point; to: Point };
type Content = { key: string; question: string; answer: FootnoteClaim[]; sources: FootnoteSource[]; read?: number };
type RunState = {
  run: number;
  key: string;
  data: Content;
  t: number;
  landed: ReadonlySet<string>;
  flying: readonly Flight[];
  folding: boolean;
};

function createRun(initial: Content) {
  let state: RunState = { run: 0, key: initial.key, data: initial, t: 0, landed: new Set(), flying: [], folding: false };
  const listeners = new Set<() => void>();
  const emit = (next: RunState) => {
    state = next;
    listeners.forEach((listener) => listener());
  };
  return {
    get: () => state,
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    advance(t: number, launch: Flight[], landNow: string[]) {
      emit({
        ...state,
        t,
        flying: launch.length ? [...state.flying, ...launch] : state.flying,
        landed: landNow.length ? new Set([...state.landed, ...landNow]) : state.landed,
      });
    },
    land(key: string) {
      if (!state.flying.some((flight) => flight.key === key)) return;
      emit({
        ...state,
        flying: state.flying.filter((flight) => flight.key !== key),
        landed: new Set([...state.landed, key]),
      });
    },
    fold() {
      emit({ ...state, folding: true });
    },
    swap(data: Content) {
      emit({ run: state.run + 1, key: data.key, data, t: 0, landed: new Set(), flying: [], folding: false });
    },
  };
}

const subscribeNever = () => () => {};

function usePageVisible() {
  return React.useSyncExternalStore(
    (onChange) => {
      document.addEventListener("visibilitychange", onChange);
      return () => document.removeEventListener("visibilitychange", onChange);
    },
    () => document.visibilityState === "visible",
    () => true,
  );
}

// A box relative to the root's padding box, in the root's own pixels even when an ancestor scales it.
function boxIn(el: Element, root: HTMLElement) {
  const box = el.getBoundingClientRect();
  const frame = root.getBoundingClientRect();
  const scale = frame.width / (root.offsetWidth || frame.width) || 1;
  return {
    x: (box.left - frame.left) / scale - root.clientLeft,
    y: (box.top - frame.top) / scale - root.clientTop,
    w: box.width / scale,
    h: box.height / scale,
  };
}

const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--footnote-accent)";

/* ── Footnote ───────────────────────────────────────────── */

type Link = { run: number; kind: "claim"; index: number } | { run: number; kind: "source"; id: string };
type Preview = { run: number; id: string; anchor: { x: number; y: number; w: number; h: number }; width: number };

export function Footnote({
  question,
  answer,
  sources,
  read,
  play = "view",
  speed = TIMING.speed,
  arc = NOTE.arc,
  flightDuration = TIMING.fly.visualDuration,
  flightBounce = TIMING.fly.bounce,
  avatar,
  status = defaultStatus,
  onComplete,
  className,
  onKeyDown,
  ...props
}: FootnoteProps) {
  const uid = React.useId();
  // useReducedMotion is already true in the hydration render, so anything drawn from it waits for hydration.
  const hydrated = React.useSyncExternalStore(subscribeNever, () => true, () => false);
  const reduced = !!useReducedMotion() && hydrated;
  const full = play === false || reduced;

  const key = `${question}␞${answer
    .map((claim) => `${claim.text}␟${[claim.cite ?? []].flat().join(",")}`)
    .join("␝")}␞${sources.map((s) => s.id).join(",")}␞${read ?? ""}`;
  const live: Content = { key, question, answer, sources, read };

  const [store] = React.useState(() => createRun(live));
  const state = React.useSyncExternalStore(store.subscribe, store.get, store.get);

  const rootRef = React.useRef<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);
  const seen = useInView(rootRef, { once: true, amount: 0.35 });
  const inView = useInView(rootRef, { amount: 0.05 });
  const visible = usePageVisible();

  const playing = !full && (play === true ? hydrated : play === "view" && seen);
  // A new question folds the old answer away only once something has played; otherwise it just swaps.
  const foldable = playing;
  const data = state.key === key || !foldable ? live : state.data;
  // A slower flight holds the finish back by as much, so the check still waits for the last number.
  const settle = TIMING.settle * (Math.max(0, flightDuration) / TIMING.fly.visualDuration);
  const plan = buildPlan(data.answer, data.sources, data.read, speed, settle);
  const run = state.run;
  const t = state.t;

  const latest = React.useRef({ live, plan, onComplete });
  React.useLayoutEffect(() => {
    latest.current = { live, plan, onComplete };
  });

  /* Swapping in a new answer. */
  const heightFrom = React.useRef<number | null>(null);
  React.useEffect(() => {
    const now = store.get();
    if (now.key === key || now.folding) return;
    if (!foldable) {
      store.swap(latest.current.live);
      return;
    }
    store.fold();
    heightFrom.current = rootRef.current?.offsetHeight ?? null;
    const content = contentRef.current;
    if (!content) return store.swap(latest.current.live);
    // Driven as a plain number, so nothing is written to the element after the swap clears it. Not
    // stopped on a re-run either: a newer question arriving mid-fold is picked up when it ends.
    animate(0, 1, {
      ...TIMING.fold,
      onUpdate: (p) => {
        content.style.opacity = String(1 - p);
        content.style.transform = `translateY(${-(NOTE.lift - 2) * p}px)`;
      },
    }).then(() => store.swap(latest.current.live));
  }, [key, foldable, store]);

  React.useLayoutEffect(() => {
    const root = rootRef.current;
    const content = contentRef.current;
    if (content) {
      content.style.opacity = "";
      content.style.transform = "";
    }
    const from = heightFrom.current;
    heightFrom.current = null;
    if (from === null || !root) return;
    const to = root.offsetHeight;
    if (Math.abs(to - from) < 1) return;
    const controls = animate(from, to, {
      ...TIMING.height,
      onUpdate: (height) => {
        root.style.height = `${height}px`;
      },
    });
    controls.then(() => {
      root.style.height = "";
    });
    return () => {
      controls.stop();
      root.style.height = "";
    };
  }, [run]);

  /* The clock: jump from event to event while it's playing and in sight. */
  const done = full || t >= plan.doneAt;
  const running = playing && !done && !state.folding && inView && visible;
  React.useEffect(() => {
    if (!running) return;
    const from = store.get();
    const next = latest.current.plan.events.find((at) => at > from.t);
    if (next === undefined) return;
    const timer = window.setTimeout(() => {
      const now = store.get();
      const root = rootRef.current;
      if (now.run !== from.run || !root) return;
      const launch: Flight[] = [];
      const landNow: string[] = [];
      for (const flight of latest.current.plan.flights) {
        if (flight.at <= from.t || flight.at > next) continue;
        const markKey = `${now.run}:${flight.claim}:${flight.num}`;
        const chip = root.querySelector(`[data-footnote-num="${CSS.escape(flight.id)}"]`);
        const mark = root.querySelector(`[data-footnote-mark="${markKey}"]`);
        if (!chip || !mark) {
          landNow.push(markKey);
          continue;
        }
        const ring = root.querySelector(`[data-footnote-ring="${CSS.escape(flight.id)}"]`);
        if (ring) animate(ring, { opacity: [0.9, 0], transform: ["scale(1)", "scale(1.12)"] }, TIMING.pulse);
        const a = boxIn(chip, root);
        const b = boxIn(mark, root);
        launch.push({ key: markKey, num: flight.num, from: { x: a.x, y: a.y }, to: { x: b.x, y: b.y } });
      }
      store.advance(next, launch, landNow);
    }, next - from.t);
    return () => window.clearTimeout(timer);
  }, [running, t, run, store]);

  // Once per answer, written and every number landed (or straight away when it doesn't play).
  const completed = React.useRef(-1);
  const finished = done && state.flying.length === 0 && !state.folding && state.key === key;
  React.useEffect(() => {
    if (!finished || completed.current === run) return;
    if (!full && !playing) return;
    completed.current = run;
    latest.current.onComplete?.();
  }, [finished, run, full, playing]);

  /* What shows at this moment. */
  const started = full || playing;
  const readCount = full ? plan.reads : playing ? plan.readAt.filter((at) => at <= t).length : 0;
  const readDone = readCount >= plan.reads;
  const shownWords = full
    ? plan.words
    : playing && t >= plan.writeAt
      ? clamp(Math.floor((t - plan.writeAt) / plan.per + 1e-6) + 1, 0, plan.words)
      : 0;
  const more = Math.max(0, readCount - plan.order.length);
  const markShown = (claim: number, num: number) =>
    full || (state.landed.has(`${run}:${claim}:${num}`) && !state.folding) || (done && state.flying.length === 0);

  /* Links between claims and sources. */
  const [link, setLink] = React.useState<Link | null>(null);
  const [preview, setPreview] = React.useState<Preview | null>(null);
  const linger = React.useRef(0);
  const intent = React.useRef(0);
  const leave = React.useRef(0);
  const pointer = React.useRef<string>("mouse");
  React.useEffect(
    () => () => {
      window.clearTimeout(linger.current);
      window.clearTimeout(intent.current);
      window.clearTimeout(leave.current);
    },
    [],
  );

  const activeLink = link && link.run === run ? link : null;
  const activePreview = preview && preview.run === run ? preview : null;
  const previewSource = activePreview ? data.sources.find((s) => s.id === activePreview.id) : undefined;

  const holdLink = (next: Link) => {
    window.clearTimeout(linger.current);
    setLink(next);
  };
  const releaseLink = () => {
    window.clearTimeout(linger.current);
    linger.current = window.setTimeout(() => setLink(null), TIMING.linger);
  };

  const litClaims = new Set<number>();
  const litSources = new Set<string>();
  if (activeLink?.kind === "claim") {
    litClaims.add(activeLink.index);
    plan.claims[activeLink.index]?.marks.forEach((mark) => litSources.add(mark.id));
  } else if (activeLink?.kind === "source") {
    litSources.add(activeLink.id);
    plan.claims.forEach((claim, i) => {
      if (claim.marks.some((mark) => mark.id === activeLink.id)) litClaims.add(i);
    });
  }
  const linking = activeLink !== null && (litClaims.size > 0 || litSources.size > 0);

  const chipOf = (id: string) =>
    rootRef.current?.querySelector<HTMLElement>(`[data-footnote-chip="${CSS.escape(id)}"]`) ?? null;

  const openPreview = (id: string) => {
    window.clearTimeout(intent.current);
    window.clearTimeout(leave.current);
    const root = rootRef.current;
    const chip = chipOf(id);
    if (!root || !chip) return;
    const anchor = boxIn(chip, root);
    setPreview({ run, id, anchor, width: Math.min(288, root.clientWidth - 16) });
  };
  const closePreview = (later: boolean) => {
    window.clearTimeout(intent.current);
    window.clearTimeout(leave.current);
    if (later) leave.current = window.setTimeout(() => setPreview(null), TIMING.leave);
    else setPreview(null);
  };
  const keepPreview = () => window.clearTimeout(leave.current);

  // A tap outside clears a tapped link or source.
  const tapped = activeLink !== null || activePreview !== null;
  React.useEffect(() => {
    if (!tapped) return;
    const onDown = (event: PointerEvent) => {
      if (event.pointerType === "mouse") return;
      if (rootRef.current?.contains(event.target as Node)) return;
      setLink(null);
      setPreview(null);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [tapped]);

  const statusText = status(readCount, readDone);
  const finalStatus = status(plan.reads, true);
  const reading = !done;
  const showStatus = started && plan.reads > 0;
  const enter = run > 0 && !full;

  return (
    <div
      ref={rootRef}
      role="article"
      aria-labelledby={`${uid}-q`}
      className={cn(
        "@container/footnote relative w-full rounded-[20px] bg-white p-4 text-zinc-950 shadow-sm ring-1 ring-zinc-950/10 @[28rem]/footnote:p-5",
        "[--footnote-accent:#5b54d6] dark:bg-zinc-900 dark:text-white dark:ring-white/10 dark:[--footnote-accent:#a49dff]",
        className,
      )}
      onPointerDownCapture={(event) => {
        pointer.current = event.pointerType;
      }}
      onKeyDown={(event) => {
        onKeyDown?.(event);
        if (event.key !== "Escape" || !activePreview) return;
        event.preventDefault();
        const chip = chipOf(activePreview.id);
        closePreview(false);
        if (chip && !chip.contains(document.activeElement)) chip.focus();
      }}
      {...props}
    >
      <div ref={contentRef}>
        <div className="flex items-start gap-2.5">
          <span
            aria-hidden
            className="mt-px grid size-6 shrink-0 place-items-center rounded-full bg-(--footnote-accent)/12 text-(--footnote-accent)"
          >
            {avatar ?? <SparkIcon className="size-3.5" />}
          </span>
          <motion.p
            key={run}
            id={`${uid}-q`}
            initial={enter ? { opacity: 0, transform: `translateY(${NOTE.lift}px)` } : false}
            animate={{ opacity: 1, transform: "translateY(0px)" }}
            transition={TIMING.rise}
            className="min-w-0 pt-0.5 text-[15px]/5 font-medium tracking-[-0.01em] text-balance"
          >
            {data.question}
          </motion.p>
        </div>

        {plan.reads > 0 && (
          <motion.div
            key={`status-${run}`}
            aria-hidden
            initial={enter ? { opacity: 0, transform: `translateY(${NOTE.lift}px)` } : false}
            animate={
              showStatus
                ? { opacity: 1, transform: "translateY(0px)" }
                : { opacity: 0, transform: `translateY(${NOTE.lift}px)` }
            }
            transition={full ? { duration: 0 } : TIMING.rise}
            className="mt-2.5 flex h-4 items-center gap-1.5 pl-[34px] text-xs text-current/60"
          >
            {reading ? <SpinnerIcon className="size-3 motion-safe:animate-spin" /> : <CheckIcon className="size-3" />}
            <span className="truncate tabular-nums">{statusText}</span>
          </motion.div>
        )}
        <span className="sr-only" aria-live="polite">
          {done && plan.reads > 0 ? finalStatus : ""}
        </span>

        <p
          key={`answer-${run}`}
          aria-busy={playing && !done}
          className="relative mt-3 text-[14px]/[1.65] text-pretty @[28rem]/footnote:text-[15px]/[1.65]"
        >
          {!full && (
            // Three lines held for the answer while the sources are read, gone as the first word lands.
            <span
              aria-hidden
              className={cn(
                `pointer-events-none absolute inset-0 overflow-hidden transition-opacity ${TIMING.skeleton}`,
                shownWords > 0 && "opacity-0",
              )}
            >
              {SKELETON.map((width, i) => (
                <span
                  key={i}
                  className="absolute left-0 h-[0.62em] rounded-full bg-current/[0.06] motion-safe:animate-pulse"
                  style={{ top: `${i * 1.65 + 0.52}em`, width }}
                />
              ))}
            </span>
          )}
          {plan.claims.map((claim, i) => {
            const lit = litClaims.has(i);
            const dim = linking && !lit;
            // Hidden words aren't there to point at yet.
            const written = claim.start + claim.words.length <= shownWords;
            const toggleClaim = () => {
              if (pointer.current === "mouse" || !written) return;
              setLink(activeLink?.kind === "claim" && activeLink.index === i ? null : { run, kind: "claim", index: i });
            };
            return (
              <React.Fragment key={i}>
                {i > 0 && " "}
                <span
                  className={cn(
                    `rounded-[4px] box-decoration-clone transition-[opacity,background-color,color] ${TIMING.link.ease}`,
                    lit || dim ? TIMING.link.on : TIMING.link.off,
                    lit ? "bg-(--footnote-accent)/12 text-current" : "text-current/80",
                    dim && "opacity-45",
                  )}
                  onPointerEnter={(event) => {
                    if (event.pointerType === "mouse" && written) holdLink({ run, kind: "claim", index: i });
                  }}
                  onPointerLeave={(event) => {
                    if (event.pointerType === "mouse") releaseLink();
                  }}
                  onClick={toggleClaim}
                >
                  {claim.words.map((word, j) => {
                    const shown = claim.start + j < shownWords;
                    const text = (
                      <span
                        className={full ? undefined : `transition-opacity ${TIMING.wordFade}`}
                        style={shown ? undefined : { opacity: 0 }}
                      >
                        {word}
                      </span>
                    );
                    if (j < claim.words.length - 1) {
                      return (
                        <React.Fragment key={j}>
                          {text}{" "}
                        </React.Fragment>
                      );
                    }
                    // The last word and its markers never wrap apart.
                    return (
                      <span key={j} className="whitespace-nowrap">
                        {text}
                        {claim.marks.map((mark) => {
                          const source = data.sources.find((s) => s.id === mark.id)!;
                          const on = markShown(i, mark.num);
                          return (
                            <button
                              key={mark.num}
                              type="button"
                              data-footnote-mark={`${run}:${i}:${mark.num}`}
                              tabIndex={on ? 0 : -1}
                              aria-label={`Source ${mark.num}: ${source.site}`}
                              className={cn(
                                `relative -top-px ml-[3px] inline-flex h-4 min-w-4 cursor-pointer items-center justify-center rounded-full px-1 align-middle text-[10px]/none font-semibold tabular-nums transition-[background-color,color] ${TIMING.tint}`,
                                lit
                                  ? "bg-(--footnote-accent) text-white dark:text-zinc-950"
                                  : "bg-(--footnote-accent)/12 text-(--footnote-accent) hover:bg-(--footnote-accent) hover:text-white dark:hover:text-zinc-950",
                                FOCUS,
                              )}
                              style={on ? undefined : { opacity: 0, pointerEvents: "none" }}
                              onFocus={() => holdLink({ run, kind: "claim", index: i })}
                              onBlur={releaseLink}
                              onClick={(event) => {
                                event.stopPropagation();
                                holdLink({ run, kind: "claim", index: i });
                                openPreview(mark.id);
                                chipOf(mark.id)?.focus({ preventScroll: true });
                              }}
                            >
                              {mark.num}
                            </button>
                          );
                        })}
                      </span>
                    );
                  })}
                </span>
              </React.Fragment>
            );
          })}
        </p>

        {plan.order.length > 0 && (
          <div
            className={cn(
              `mt-4 border-t border-current/[0.08] pt-3 transition-[border-color] ${TIMING.rule}`,
              readCount === 0 && "border-transparent",
            )}
          >
            <ul aria-label="Sources" className="flex flex-wrap gap-1.5">
              {plan.order.map(({ source, num }, i) => {
                const dealt = i < readCount;
                const lit = litSources.has(source.id);
                const dim = linking && !lit;
                const open = activePreview?.id === source.id;
                const Tag = source.href ? "a" : "button";
                return (
                  <li
                    key={source.id}
                    className={cn(
                      `flex min-w-0 transition-[opacity,translate,scale] ${TIMING.chip.ease}`,
                      lit || dim ? TIMING.chip.on : TIMING.chip.off,
                      !dealt ? "pointer-events-none scale-[0.96] opacity-0" : dim ? "opacity-50" : "opacity-100",
                      lit && "-translate-y-0.5",
                    )}
                  >
                    <Tag
                      {...(source.href
                        ? { href: source.href, target: "_blank", rel: "noreferrer" }
                        : { type: "button" as const, "aria-expanded": open })}
                      data-footnote-chip={source.id}
                      tabIndex={dealt ? undefined : -1}
                      aria-describedby={open ? `${uid}-source` : undefined}
                      className={cn(
                        `relative flex h-7 min-w-0 max-w-[13rem] cursor-pointer items-center gap-1.5 rounded-full pr-2.5 pl-1.5 text-xs ring-1 transition-[background-color,box-shadow,color] ${TIMING.tint}`,
                        lit || open
                          ? "bg-(--footnote-accent)/10 text-current ring-(--footnote-accent)/45"
                          : "bg-current/[0.04] text-current/75 ring-current/10 hover:bg-current/[0.07] hover:text-current",
                        FOCUS,
                      )}
                      onPointerEnter={(event: React.PointerEvent) => {
                        if (event.pointerType !== "mouse") return;
                        holdLink({ run, kind: "source", id: source.id });
                        window.clearTimeout(leave.current);
                        window.clearTimeout(intent.current);
                        if (!open) intent.current = window.setTimeout(() => openPreview(source.id), TIMING.intent);
                      }}
                      onPointerLeave={(event: React.PointerEvent) => {
                        if (event.pointerType !== "mouse") return;
                        releaseLink();
                        closePreview(true);
                      }}
                      onFocus={(event: React.FocusEvent<HTMLElement>) => {
                        holdLink({ run, kind: "source", id: source.id });
                        if (event.currentTarget.matches(":focus-visible")) openPreview(source.id);
                      }}
                      onBlur={() => {
                        releaseLink();
                        closePreview(true);
                      }}
                      onKeyDown={(event: React.KeyboardEvent) => {
                        if (event.key !== " " || !source.href) return;
                        event.preventDefault();
                        if (open) closePreview(false);
                        else openPreview(source.id);
                      }}
                      onClick={(event: React.MouseEvent) => {
                        const touch = pointer.current !== "mouse" && event.detail > 0;
                        if (!source.href || touch) {
                          event.preventDefault();
                          if (open) {
                            closePreview(false);
                            setLink(null);
                          } else {
                            openPreview(source.id);
                            if (touch) setLink({ run, kind: "source", id: source.id });
                          }
                        }
                      }}
                    >
                      <span
                        data-footnote-num={num === undefined ? undefined : source.id}
                        className={cn(
                          `grid h-4 min-w-4 shrink-0 place-items-center rounded-full px-1 text-[10px]/none font-semibold tabular-nums transition-[background-color,color] ${TIMING.tint}`,
                          lit
                            ? "bg-(--footnote-accent) text-white dark:text-zinc-950"
                            : "bg-current/[0.08] text-current/70",
                        )}
                        aria-hidden
                      >
                        {num ?? monogram(source.site)}
                      </span>
                      <span className="truncate">{source.site}</span>
                      <span
                        data-footnote-ring={source.id}
                        aria-hidden
                        className="pointer-events-none absolute inset-0 rounded-full opacity-0 ring-2 ring-(--footnote-accent)"
                      />
                    </Tag>
                  </li>
                );
              })}
              {plan.reads > plan.order.length && (
                <li
                  className={cn(
                    `flex h-7 items-center rounded-full px-2.5 text-xs text-current/55 tabular-nums transition-[opacity,scale] ${TIMING.more}`,
                    more > 0 ? "opacity-100" : "scale-[0.96] opacity-0",
                  )}
                >
                  +{full ? plan.reads - plan.order.length : more} more
                  <span className="sr-only"> sources</span>
                </li>
              )}
            </ul>
          </div>
        )}
      </div>

      <div aria-hidden className="pointer-events-none absolute inset-0 z-10">
        {state.flying.map((flight) => (
          <FlyingNumber
            key={flight.key}
            flight={flight}
            arc={arc}
            duration={flightDuration}
            bounce={flightBounce}
            onLand={store.land}
          />
        ))}
      </div>

      <AnimatePresence>
        {activePreview && previewSource && (
          <SourceCard
            key={`${activePreview.run}:${activePreview.id}`}
            id={`${uid}-source`}
            source={previewSource}
            preview={activePreview}
            reduced={reduced}
            onPointerEnter={keepPreview}
            onPointerLeave={() => closePreview(true)}
          />
        )}
      </AnimatePresence>
    </div>
  );
}

/* ── FlyingNumber ───────────────────────────────────────── */

// A copy of a chip's number on its way to the claim's marker. It leaves lit in the accent and
// lands looking exactly like the marker, which shows in the same frame this copy goes away. The
// marker is measured again each frame, so a reflow mid-flight (a resize, a late font) still lands.
function FlyingNumber({
  flight,
  arc,
  duration,
  bounce,
  onLand,
}: {
  flight: Flight;
  arc: number;
  duration: number;
  bounce: number;
  onLand: (key: string) => void;
}) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const lit = React.useRef<HTMLSpanElement>(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    const root = el?.parentElement?.parentElement;
    if (!el || !root) return;
    const mark = root.querySelector(`[data-footnote-mark="${flight.key}"]`);
    const { from } = flight;
    const place = (p: number) => {
      const to = mark ? boxIn(mark, root) : flight.to;
      const peak = { x: (from.x + to.x) / 2, y: Math.min(from.y, to.y) - arc };
      const u = 1 - p;
      const x = u * u * from.x + 2 * u * p * peak.x + p * p * to.x;
      const y = u * u * from.y + 2 * u * p * peak.y + p * p * to.y;
      const scale = 1 + NOTE.grow * Math.sin(Math.PI * clamp(p, 0, 1));
      el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
      if (lit.current) lit.current.style.opacity = String(1 - smoothstep(0.45, 0.95, p));
    };
    place(0);
    const controls = animate(0, 1, {
      ...TIMING.fly,
      visualDuration: duration,
      bounce,
      onUpdate: place,
      onComplete: () => onLand(flight.key),
    });
    return () => controls.stop();
  }, [flight, onLand, arc, duration, bounce]);
  return (
    <span
      ref={ref}
      className="absolute top-0 left-0 grid h-4 min-w-4 origin-center place-items-center rounded-full bg-(--footnote-accent)/12 px-1 text-[10px]/none font-semibold text-(--footnote-accent) tabular-nums will-change-transform"
    >
      {flight.num}
      <span
        ref={lit}
        className="absolute inset-0 grid place-items-center rounded-full bg-(--footnote-accent) text-white shadow-sm dark:text-zinc-950"
      >
        {flight.num}
      </span>
    </span>
  );
}

/* ── SourceCard ─────────────────────────────────────────── */

// The source, grown out of its chip: above it when there's room in the card, below it when not.
function SourceCard({
  id,
  source,
  preview,
  reduced,
  onPointerEnter,
  onPointerLeave,
}: {
  id: string;
  source: FootnoteSource;
  preview: Preview;
  reduced: boolean;
  onPointerEnter: () => void;
  onPointerLeave: () => void;
}) {
  const ref = React.useRef<HTMLDivElement>(null);
  const { anchor, width } = preview;

  React.useLayoutEffect(() => {
    const el = ref.current;
    const root = el?.offsetParent as HTMLElement | null;
    if (!el || !root) return;
    const left = clamp(anchor.x - 6, 8, Math.max(8, root.clientWidth - width - 8));
    const height = el.offsetHeight;
    const above = anchor.y - NOTE.gap - height >= 4;
    el.style.left = `${left}px`;
    el.style.top = `${above ? anchor.y - NOTE.gap - height : anchor.y + anchor.h + NOTE.gap}px`;
    el.style.transformOrigin = `${anchor.x + Math.min(anchor.w, 28) / 2 - left}px ${above ? "100%" : "0%"}`;
  }, [anchor, width]);

  const passage = source.snippet ? markQuote(source.snippet, source.quote) : null;
  return (
    <motion.div
      ref={ref}
      id={id}
      initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.92 }}
      animate={{ opacity: 1, scale: 1, transition: reduced ? TIMING.close : TIMING.open }}
      exit={reduced ? { opacity: 0, transition: TIMING.close } : { opacity: 0, scale: 0.96, transition: TIMING.close }}
      // Top, left and the origin are set before paint, once the card's height is known.
      style={{ width }}
      className="absolute z-20 rounded-2xl bg-white p-3 text-left text-zinc-950 shadow-lg ring-1 shadow-zinc-950/10 ring-zinc-950/10 dark:bg-zinc-800 dark:text-white dark:shadow-black/40 dark:ring-white/10"
      onPointerEnter={(event) => {
        if (event.pointerType === "mouse") onPointerEnter();
      }}
      onPointerLeave={(event) => {
        if (event.pointerType === "mouse") onPointerLeave();
      }}
    >
      <div className="flex items-center gap-1.5 text-[11px] text-zinc-500 dark:text-zinc-400">
        <span
          aria-hidden
          className="grid size-4 shrink-0 place-items-center rounded-full bg-zinc-950/[0.06] text-[9px] font-semibold text-zinc-600 dark:bg-white/10 dark:text-zinc-300"
        >
          {monogram(source.site)}
        </span>
        <span className="truncate">{source.site}</span>
      </div>
      <p className="mt-1.5 text-[13px]/5 font-medium tracking-[-0.005em]">
        {source.href ? (
          <a
            href={source.href}
            target="_blank"
            rel="noreferrer"
            tabIndex={-1}
            className="underline-offset-2 hover:underline"
          >
            {source.title}
            <ArrowIcon className="ml-1 inline size-3 -translate-y-px text-zinc-400" />
          </a>
        ) : (
          source.title
        )}
      </p>
      {passage && (
        <p className="mt-1.5 line-clamp-4 text-xs/[1.55] text-zinc-600 dark:text-zinc-300">
          “{passage.before}
          {passage.quote && (
            <mark className="rounded-[3px] bg-(--footnote-accent)/15 px-0.5 text-zinc-950 box-decoration-clone dark:text-white">
              {passage.quote}
            </mark>
          )}
          {passage.after}”
        </p>
      )}
    </motion.div>
  );
}

function markQuote(snippet: string, quote?: string) {
  const at = quote ? snippet.toLowerCase().indexOf(quote.toLowerCase()) : -1;
  if (!quote || at < 0) return { before: snippet, quote: "", after: "" };
  return { before: snippet.slice(0, at), quote: snippet.slice(at, at + quote.length), after: snippet.slice(at + quote.length) };
}

/* ── Icons ──────────────────────────────────────────────── */

function SparkIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden>
      <path
        d="M8 1.5c.3 2.9 1.1 4.6 2.2 5.4 1 .8 2.4 1.1 4.3 1.1-1.9 0-3.3.3-4.3 1.1C9.1 9.9 8.3 11.6 8 14.5c-.3-2.9-1.1-4.6-2.2-5.4C4.8 8.3 3.4 8 1.5 8c1.9 0 3.3-.3 4.3-1.1C6.9 6.1 7.7 4.4 8 1.5Z"
        fill="currentColor"
      />
    </svg>
  );
}

function SpinnerIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
      <circle cx="6" cy="6" r="4.75" stroke="currentColor" strokeOpacity={0.25} strokeWidth={1.5} />
      <path d="M6 1.25A4.75 4.75 0 0 1 10.75 6" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
    </svg>
  );
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
      <circle cx="6" cy="6" r="5.25" fill="currentColor" fillOpacity={0.14} />
      <path d="m3.8 6.1 1.5 1.5 2.9-3.1" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function ArrowIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
      <path d="M4 8l4-4M4.5 4H8v3.5" stroke="currentColor" strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}
```
