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.
registry/blocks/footnote-feature.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>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/footnote-feature