Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  BookMarked,
  CircleHelp,
  ExternalLink,
  FileText,
  Globe,
  Link2Off,
  ListOrdered,
  Lock,
  MessagesSquare,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/citation-list.json

Prompt

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-maintained n fields 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 now prop rather than a clock read during render, and the absolute form is printed in an explicit timeZone (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 mark elements. Nothing is ever handed to dangerouslySetInnerHTML, so a crawler that swallowed an img or script tag renders those characters instead of creating an element.

On This Page