Citation List
The numbered reference list under an AI answer — numbering derived exactly the way Citation Chip derives it so the markers and the list can never disagree, repeated sources merged with a citation count, graded trust that never leans on colour, and sources with no usable URL that refuse to become dead links.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/citation-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CitationList" component (zod for the
contract, lucide-react for icons; no other dependencies) — the numbered
reference block that sits under an AI answer, one row per source.
Contract
- src/…/citation-list.contract.ts exports zod schemas + inferred types.
CitationListItem = { id: string; title: string; url?: string; domain?: string;
excerpt?: string; kind?: "web" | "file" | "internal"; unavailable?: boolean;
trust?: "official" | "reference" | "community" | "unverified";
relevance?: number (0-1); retrievedAt?: ISO 8601 with offset }.
The envelope is { status: "loading" | "empty" | "error" | "ready";
items: CitationListItem[] }.
- CitationList = forwardRef<HTMLDivElement, CitationListProps>, props:
items, status, now (string | number | Date, REQUIRED), locale = "en-US",
timeZone = "UTC", highlight?: string | string[], onRetry?, skeletonRows = 4,
emptyState?, label = "Sources", target?, rel?, plus the rest of
React.HTMLAttributes<HTMLDivElement>.
- The first six fields are deliberately name-identical to the inline marker
component's CitationSource, so ONE array literal feeds both. The extra three
are ignored by the marker.
Behavior — numbering is the whole point
- NUMBERING IS NOT A FIELD and is NOT the caller's job. Walk `items` in order,
skip ids already seen, and hand out 1, 2, 3… to unique ids in first-appearance
order. That is byte-for-byte the rule the inline `[n]` marker's scope uses, so
the markers in the prose and the rows in this list agree BY CONSTRUCTION —
not because the caller kept two numberings in sync. Verified in a browser
with one shared array: marker sequence [1,2,1,3,2,4,1], list sequence
[1,2,3,4], and every id maps to the same number on both sides.
- A REPEAT OF AN ID MEANS "cited again". Repeats merge into one row that carries
the count, rendered as "Cited 3×" plus an sr-only "Cited 3 times in this
answer". The badge only appears when the count is above 1 — a "Cited 1×" on
every row is noise. The header states the two totals separately and only when
they differ ("4 sources · 7 citations").
- The FIRST occurrence's payload wins for a repeated id. A retrieval pipeline
that emits a thinner record the second time must not renumber or blank a row
that is already on screen.
- Each row carries data-source-id, so a marker's click handler can find and
highlight its row without this component owning any of that behaviour.
Behavior — sources you cannot link to
- Resolve the href defensively: trim, treat "" / whitespace / "#" as absent,
allow relative paths, otherwise accept only http/https/mailto. Citation
payloads come out of a crawler or a model, so a `javascript:` URL is a live
possibility and must never reach the DOM.
- No usable href, or `unavailable: true` (404, retracted, paywalled): render the
title as a plain paragraph, never an anchor, and add a note row answering the
only remaining question — "No link available" / "Link unavailable". When there
WAS a URL, keep it as struck-through selectable text: it is still evidence,
just not a destination. Verified: 7 link-less rows produced 0 anchors, and the
whole page had 0 `a[href="#"]`, 0 empty hrefs and 0 `javascript:` hrefs.
- `kind` picks the glyph and the fallback source line: Globe / FileText / Lock,
and Link2Off when the link is known dead.
Behavior — grading, and refusing to invent one
- `trust` is optional and HAS NO DEFAULT. A pipeline that does not grade its
corpus leaves it out and the row shows no rating at all. Defaulting to
"unverified" would put a fabricated fact in the same slot as a real one.
Same for `relevance` and `retrievedAt`. Verified: an id/title/url-only payload
renders zero rating text while the graded payload on the same page renders its
tiers.
- Every tier is told apart by an ICON and a WORD before colour is involved
(ShieldCheck/Official, BookMarked/Reference, MessagesSquare/Community,
CircleHelp/Unverified), and the weakest tier additionally gets a dashed
outline — the ranking survives greyscale and a re-themed palette. Each badge's
title attribute spells out what the tier means.
- `relevance` prints the number ("Relevance 93%"); the 40px bar next to it is
aria-hidden decoration that repeats the number, never the only way to read it.
Clamp it to 0-1 and drop non-finite values.
Behavior — time
- A cited web page can be edited or deleted after the answer was written, so
`retrievedAt` records WHEN THE PAGE WAS FETCHED, not when it was published.
- `now` is a REQUIRED prop, never Date.now() in render: that is what makes SSR
and hydration produce the same string and every screenshot reproducible. The
relative label is coarsest-fit via Intl.RelativeTimeFormat(locale, {numeric:
"auto", style: "narrow"}).
- The absolute timestamp on hover is formatted in `timeZone`, default "UTC", and
the zone is printed with it. UTC is the only zone that cannot drift between
the server and the reader's machine; passing the reader's zone is supported
but then the component must be client-only, or a server-rendered UTC string
will hydrate over a local one. Unparseable instants render nothing rather
than "Invalid Date".
Behavior — excerpts are untrusted text
- Highlighting is STRUCTURAL: split the excerpt with one capturing group built
from the escaped `highlight` terms (longest first, case-insensitive) and wrap
the odd indices in <mark>. No dangerouslySetInnerHTML anywhere. Verified with
an excerpt containing an img/onerror tag, a script tag, an svg/onload tag and
an iframe: 0 of each element in the DOM, the excerpt's textContent byte-equal
to the input, and the sentinel the payload tried to set never appeared.
- Do NOT clamp the excerpt with line-clamp. A silently cut excerpt is a citation
you cannot check; if you add clamping, add a real disclosure control with it.
Behavior — the four states
- loading: an aria-hidden skeleton whose geometry matches a real row (number
square + three text bars), with an sr-only role="status", skeletonRows clamped
to 1-12, and the pulse switched off under motion-reduce.
- empty: "no sources cited" — an answer written WITHOUT retrieval. This is not
the error state and must not read like one.
- error: the retrieval sidecar failed while the answer above may be perfectly
fine; say so. The retry button renders only when onRetry is passed, so there
is never a dead affordance.
- ready: header bar with the label and the two counts, then an <ol role="list">
(Tailwind's preflight removes list semantics from a styled <ol>, so the role
is restored explicitly).
Rendering & styling
- Semantic tokens only: bg-card, bg-muted, bg-primary, bg-primary/15,
border, border-primary/45, border-dashed, text-foreground,
text-muted-foreground, text-destructive, ring-ring. No hex/rgb/oklch.
- LONG STRINGS: `wrap-anywhere` (overflow-wrap: anywhere) on every text box is
the load-bearing guard and it is the ONLY one. It drops each box's min-content
width to one character, so a 150-character URL cannot widen the row. Measured
at 375px with that class stripped: +519px of real horizontal document scroll
(confirmed with an actual sideways wheel gesture: scrollX 529). `min-w-0` on
the same columns was measured to change nothing while wrap-anywhere is
present — all 24 probes stayed green with it removed — so it is not in the
markup. Do not truncate: nothing here is clipped, everything wraps.
- Contrast measured by compositing through the ancestor chain onto a canvas
(light / dark): title 19.8 / 18.97, excerpt 4.95 / 7.66, `<mark>` 14.44 /
13.75, number badge 4.54 / 6.94, domain and header 4.95 / 7.66. The number
badge deliberately reuses the inline marker's bg-muted + text-muted-foreground
so the two read as the same object; that pairing is the thinnest margin in the
component, so do not stack extra opacity on it.
- Accessibility: the number badge carries an sr-only "Citation " prefix, the
count badge an sr-only long form, the loading branch an sr-only role="status",
and every icon is aria-hidden. Nothing in a row is clickable except the source
link itself — a row-wide click target here would be an affordance with no
behaviour behind it.
Customization levers
- Row anatomy: header line, trust badge, relevance, retrieved-at, excerpt and
the no-link note are each independently droppable — deleting any one of them
is a local edit with no effect on numbering or merging.
- Density: px-3 py-3 and gap-1.5 are the knobs; drop the excerpt for a compact
variant rather than clamping it.
- Trust vocabulary: TRUST_META is a plain record — rename the tiers, swap the
glyphs, add a fifth. Keep icon + word as the primary channel and keep the
option of "no value" meaning "no badge".
- Numbering: replace mergeSources with your own ordering (by domain, by
relevance) ONLY if you change the inline marker's scope the same way — the
guarantee this component sells is that the two walks are identical.
- Pairing: each row exposes data-source-id, so `[data-source-id="…"]` is the
hook for scroll-into-view or a highlight when a marker is clicked.
- locale / timeZone are per-instance; label renames the header and the list's
accessible name.Concepts
- Derived numbering, not authored numbering — the number is never a field on the payload. Both the inline marker's scope and this list walk the same array, skip ids they have already seen, and hand out
1, 2, 3…to unique ids in first-appearance order. Two independent walks of one array cannot drift; two hand-maintainednfields can. - Repeat means cited again — a repeated id is how the payload says "the answer leaned on this a second time". The list merges the repeats into one row and reports
Cited 3×; the first occurrence's payload wins, so a thinner second record can never renumber or blank a row already on screen. - Grading with an empty state — trust is a four-tier optional field with no default, distinguished by icon and word before colour, with a dashed outline on the weakest tier. A pipeline that does not grade its corpus renders no badge at all: an invented "unverified" would sit in the same slot as a measured one and read as a fact.
- Retrieval time, not publication time — a cited page can change or vanish after the answer was written, so the row records when the crawler read it. The instant it is compared against is a required
nowprop rather than a clock read during render, and the absolute form is printed in an explicittimeZone(UTC by default) so the server and the reader's machine cannot disagree. - Honest absence over dead links — an internal page, an uploaded file, a
javascript:href or a URL known to be dead all downgrade the title from an anchor to plain text plus a note that says why. The original address stays visible as struck-through text, because it is still evidence even when it is no longer a destination. - Structural highlighting — the excerpt is split on the highlight terms and reassembled as text nodes wrapped in
markelements. Nothing is ever handed todangerouslySetInnerHTML, so a crawler that swallowed animgorscripttag renders those characters instead of creating an element.
Webhook Log
A webhook delivery log with the retry lifecycle built in — outcome classes that keep timeouts separate from status codes, a countdown to the next attempt derived from an injected clock, masked credential headers, collapsed payloads with honest truncation, and a guarded replay confirmation.
Dependency List
An auditable dependency tree with real semver comparison, graded staleness, ordered CVE severities, licence obligation classes and import paths.