# Footnote Feature

> A feature section about answers you can check. Beside three plain points, a Footnote answers a traveler's question: it reads its sources, writes the answer and flies each citation from its source into the sentence it backs. Follow-up questions under the card ask the next one, and the old answer folds away.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/footnote-feature).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/footnote-feature
- Screenshot: https://shadcn.exchange/shot/keyframeui/footnote-feature/light.png
- Registry item: https://keyframeui.com/r/footnote-feature.json
- Categories: blocks, feature-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/footnote-feature.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/footnote.json`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/footnote-feature

## Files

### `registry/blocks/footnote-feature.tsx`

```tsx
"use client";

import * as React from "react";

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

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the section yours: the copy,
 * the three points beside the answer, and the questions it
 * answers, each with the sources it cites.
 * ─────────────────────────────────────────────────────────── */

/** The one accent: the citation numbers and the highlights in the answer. */
const ACCENT = { light: "#5b54d6", dark: "#a49dff" };

const EYEBROW = "Sources, not guesses";
const HEADLINE = "Every time, price and opening hour links to where it came from.";
const POINTS: { title: string; body: string; icon: "read" | "tap" | "unsure" }[] = [
  {
    title: "Reads before it writes",
    body: "Timetables, opening hours and local guides come first. The plan comes after.",
    icon: "read",
  },
  {
    title: "One tap to the source",
    body: "Each claim carries a number. Tap it to see the page and the line it relied on.",
    icon: "tap",
  },
  {
    title: "Says what it couldn't check",
    body: "No timetable online? It tells you to call, and gives you the number.",
    icon: "unsure",
  },
];
const ASK_LABEL = "Ask next";

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

const SOURCES = {
  museum: {
    id: "museum",
    site: "rivermuseum.org",
    title: "River Museum: opening hours",
    href: "#river-museum",
    snippet: "Open Tuesday to Sunday, 10:00 to 18:00. Closed on Mondays and public holidays.",
    quote: "Closed on Mondays",
  },
  market: {
    id: "market",
    site: "vilabrava.town",
    title: "Covered market: days and hours",
    href: "#market",
    snippet: "The covered market opens Monday to Saturday from 07:00 to 13:00.",
    quote: "Monday to Saturday from 07:00 to 13:00",
  },
  boats: {
    id: "boats",
    site: "riverboats.travel",
    title: "Boat trips: timetable",
    href: "#boats",
    snippet:
      "Boats leave Vila Brava quay at 11:00 and 15:30 every day from April to October. The 15:30 boat stops at Pinhal quay, ten minutes' walk from the station.",
    quote: "at 11:00 and 15:30 every day",
  },
  estates: {
    id: "estates",
    site: "bravaestates.org",
    title: "Wine estates: visits and bookings",
    href: "#estates",
    snippet: "Quinta Lume and Casa Alta open seven days a week. The other estates close on Mondays.",
    quote: "open seven days a week",
  },
  rail: {
    id: "rail",
    site: "valleyline.travel",
    title: "Valley Line timetable, winter 2026",
    href: "#valley-line",
    snippet: "Trains stop at Vila Brava and Pinhal every two hours. The last train back to Porto Lume leaves at 19:40.",
    quote: "every two hours",
  },
  bikes: {
    id: "bikes",
    site: "bravabikes.co",
    title: "E-bike hire",
    href: "#bikes",
    snippet: "E-bikes from €28 a day. One charge covers the 34 km river loop twice over.",
    quote: "covers the 34 km river loop twice over",
  },
  inns: {
    id: "inns",
    site: "staybrava.com",
    title: "Inns near the station",
    href: "#inns",
    snippet: "Casa do Rio is 300 m from the station, with rooms from €95 a night.",
    quote: "300 m from the station",
  },
  ridge: {
    id: "ridge",
    site: "serraalta.trails",
    title: "North Ridge viewpoint",
    href: "#north-ridge",
    snippet: "The upper car park is 12 minutes by taxi from Vila Brava station, and the viewpoint is 15 minutes on foot from there.",
    quote: "12 minutes by taxi",
  },
} satisfies Record<string, FootnoteSource>;

const TURNS: Turn[] = [
  {
    question: "What's actually open in Vila Brava on a Monday?",
    answer: [
      { text: "The River Museum is closed, but the covered market runs until 13:00.", cite: ["museum", "market"] },
      { text: "Boat trips still leave the quay at 11:00 and 15:30.", cite: "boats" },
      { text: "Only Quinta Lume and Casa Alta open their cellars on Mondays, so book one of those.", cite: "estates" },
    ],
    sources: [SOURCES.museum, SOURCES.market, SOURCES.boats, SOURCES.estates],
    read: 8,
  },
  {
    question: "Can we do the valley without a car?",
    answer: [
      { text: "Yes: the Valley Line stops at Vila Brava and Pinhal every two hours.", cite: "rail" },
      { text: "Hire an e-bike for the river loop; one charge covers its 34 km twice.", cite: "bikes" },
      { text: "Take the 15:30 boat to Pinhal and walk ten minutes to the train.", cite: ["boats", "rail"] },
    ],
    sources: [SOURCES.rail, SOURCES.bikes, SOURCES.boats],
    read: 6,
  },
  {
    question: "Where should we stay for both sunrise and the last train?",
    answer: [
      { text: "Casa do Rio is 300 m from the station, so the 19:40 home is never a rush.", cite: ["inns", "rail"] },
      { text: "For sunrise, a taxi reaches the North Ridge car park in 12 minutes.", cite: "ridge" },
    ],
    sources: [SOURCES.inns, SOURCES.rail, SOURCES.ridge],
    read: 7,
  },
];

/* ── Section ─────────────────────────────────────────────── */

export function FootnoteFeature() {
  const [current, setCurrent] = React.useState(0);
  const turn = TURNS[current];

  return (
    <section
      aria-labelledby="footnote-feature-title"
      style={{ "--accent-light": ACCENT.light, "--accent-dark": ACCENT.dark } as React.CSSProperties}
      className="bg-[#f3f1ec] text-zinc-950 [--footnote-accent:var(--accent-light)] dark:bg-zinc-950 dark:text-white dark:[--footnote-accent:var(--accent-dark)]"
    >
      <div className="mx-auto grid max-w-6xl items-start gap-12 px-6 py-24 sm:py-32 lg:grid-cols-[1fr_1.1fr] lg:gap-16">
        <div>
          <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">{EYEBROW}</p>
          <h2
            id="footnote-feature-title"
            className="mt-3 max-w-[18ch] text-4xl/[1.05] font-medium tracking-[-0.035em] text-balance sm:text-5xl/[1.02]"
          >
            {HEADLINE}
          </h2>
          <ul className="mt-10 flex flex-col gap-6">
            {POINTS.map((point) => (
              <li key={point.title} className="flex gap-4">
                <span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl bg-white text-(--footnote-accent) shadow-sm ring-1 ring-zinc-950/5 dark:bg-white/[0.06] dark:ring-white/10">
                  <PointIcon name={point.icon} className="size-4" />
                </span>
                <span>
                  <span className="block text-[15px] font-medium tracking-tight">{point.title}</span>
                  <span className="mt-1 block max-w-sm text-sm/6 text-pretty text-zinc-600 dark:text-zinc-400">{point.body}</span>
                </span>
              </li>
            ))}
          </ul>
        </div>

        <div className="rounded-[2rem] bg-white/50 p-3 ring-1 ring-zinc-950/5 sm:p-5 dark:bg-white/[0.03] dark:ring-white/[0.06]">
          <Footnote
            question={turn.question}
            answer={turn.answer}
            sources={turn.sources}
            read={turn.read}
            avatar={<MeanderMark className="size-3.5" />}
            className="shadow-sm"
          />
          <div className="mt-4 flex flex-wrap items-center gap-1.5 px-1">
            <span className="mr-1 text-xs text-zinc-500 dark:text-zinc-400">{ASK_LABEL}</span>
            {TURNS.map((t, i) =>
              i === current ? null : (
                <button
                  key={t.question}
                  type="button"
                  onClick={() => setCurrent(i)}
                  className="inline-flex min-h-8 items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-left 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 shrink-0 text-zinc-400" />
                  {t.question}
                </button>
              ),
            )}
          </div>
        </div>
      </div>
    </section>
  );
}

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

function PointIcon({ name, className }: { name: "read" | "tap" | "unsure"; className?: string }) {
  const paths = {
    read: "M2.5 3.5h4a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 0-1.5-1.5h-4Zm11 0h-4A1.5 1.5 0 0 0 8 5v8a1.5 1.5 0 0 1 1.5-1.5h4Z",
    tap: "M6 9.5V3.75a1.25 1.25 0 0 1 2.5 0V8l3 .6a1.6 1.6 0 0 1 1.3 1.8l-.5 3.1H6.6L4.4 10.8a1.1 1.1 0 0 1 1.6-1.3Z",
    unsure: "M6.2 6.2a1.9 1.9 0 1 1 2.4 1.8c-.4.2-.6.5-.6.9v.6M8 11.6v.1M14 8A6 6 0 1 1 2 8a6 6 0 0 1 12 0Z",
  } as const;
  return (
    <svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth={1.4} aria-hidden>
      <path d={paths[name]} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function MeanderMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" className={className} fill="none" aria-hidden>
      <path
        d="M3.5 17.5c0-3.6 5.5-3.4 5.5-7S4.6 6.2 7.2 2.5M11 17.5c0-3 5.5-3 5.5-6.4"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
      />
    </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>
  );
}
```
