Citations
Inline citation markers for a streamed AI answer and the numbered source list they point at — pending until their source arrives, named links with a preview on hover and focus, and a highlight shared between marker and entry.
pnpm add @tessinaui/uiWhen to use
- An AI answer that cites its sources. Each claim carries a numbered marker in the text and the sources are listed under the answer.
- A streamed answer. A marker can arrive before its source does; it shows as pending and becomes the numbered link in place, so the text never reflows or renumbers.
- A quick check without leaving the page. Hover or focus a marker and a short preview names the source; the list entry highlights with it.
- Sources the reader should be able to verify. Give each one a title and a
url; add adomain,descriptionoriconwhen they help the reader recognise it. - A list of your own.
useCitations()returns the numbered entries when the stock list does not fit.
When not to use
- A link in prose. That is an ordinary markdown link, and
citationComponentshands it back to the answer unchanged. Use Link outside an answer. - Sources with no inline markers. Use
ChatBubbleSourceson its own; see ChatBubble. - Footnotes. A
[^1]needs a definition that arrives in a later block, so it cannot be matched while the answer streams. Cite with#cite:<id>instead. - Answers that do not stream or use markdown. Markers are drawn from the answer's markdown; render the text with StreamingResponse so they resolve.
Installation
pnpm add @tessinaui/uiUsage
import { Citations, citationComponents } from "@tessinaui/ui/citations";
import { StreamingResponse } from "@tessinaui/ui/streaming-response";
import { ChatBubbleSource, ChatBubbleSources } from "@tessinaui/ui/chat-bubble";
<Citations sources={sources}>
<StreamingResponse markdown={text} components={citationComponents} />
<ChatBubbleSources label="Sources">
{sources.map((s) => (
<ChatBubbleSource key={s.id} citationId={s.id} />
))}
</ChatBubbleSources>
</Citations>In the answer, a citation is an inline markdown link whose href names a
source: [1](#cite:shade). The label is ignored — the marker draws its own
number — and the #cite: href is discarded: the marker links to the source
itself.
Examples
Default
Hover or focus a marker and its entry in the list highlights, and the other way round.
Streaming
Sources arrive with the text, and not always before the citation that points at
them. A marker whose source has not arrived is a pending chip with a dot;
when the source lands, the same element becomes the numbered link. A citation
that is still being typed is a pending marker too, from the # after ](
onward — never the raw [1](#cite:sh text. The [1]( before that # could be
any link, so for a token it shows as text, as any half-typed link does.
Source list
CitationSources is the answer's sources as one row under it: a pill with the
stacked marks and the count, which opens to a card per source. Each card shows
the source's mark and site, its number at the end, and its title beneath. Inside
Citations a card takes its source's number and link and highlights with the
answer's markers. The cards are two columns in a wide row and one
snap-scrolling row in a narrow one. It renders nothing without sources.
CitationMarkStack is the overlapping marks on their own, for a count line or a
header; it is decorative, so pair it with text that says what it counts.
In a conversation
On color
Right to left
API Reference
Citations
| Prop | Type | Default | Description |
|---|---|---|---|
sources | CitationSource[] | [] | The answer's sources, in numbering order. Append only |
dir | "ltr" | "rtl" | — | "rtl" picks the Arabic strings; layout follows the page |
strings | Partial<CitationsStrings> | — | Per-entry string overrides |
CitationSource
| Field | Type | Description |
|---|---|---|
id | string | What #cite:<id> in the answer points at |
title | string | Shown in the preview and the list |
url | string | Where the marker and the list entry link, in a new tab |
domain | string | Shown in the marker's name and the preview; read from url when omitted |
description | string | A line of excerpt in the preview |
icon | ReactNode | A favicon or logo. The library fetches nothing — pass your own |
CitationMarker
Rendered for you by citationComponents; use it directly in content you
render yourself.
| Prop | Type | Default | Description |
|---|---|---|---|
sourceId | string | — | The source this marker cites |
size | "xs" … "xl" | the answer's | Type size of the number |
tone | "default" | "on-color" | the answer's | Inherited from StreamingResponse, then the surface |
CitationSources
| Prop | Type | Default | Description |
|---|---|---|---|
sources | CitationSource[] | — | The sources to list. Renders nothing when empty |
open · defaultOpen · onOpenChange | boolean | closed | Whether the cards show |
dir | "ltr" | "rtl" | — | "rtl" picks the Arabic strings |
strings | Partial<CitationSourcesStrings> | — | count and countOne (with {count}), and list, the name of the card list |
CitationMarkStack
| Prop | Type | Default | Description |
|---|---|---|---|
sources | CitationSource[] | — | The sources whose marks stack |
max | number | 4 | How many marks to stack |
The marks part along the row on hover, focus and while open, so a count beside them never sits under a mark. Under reduced motion they part at once.
citationComponents
{ a: CitationAnchor }, one frozen object. Pass it as StreamingResponse's
components. To add your own overrides, spread it into a map you also keep
stable — { ...citationComponents, p: MyParagraph } defined outside the render —
because a new map on every render re-renders every block of the answer.
ChatBubbleSource citationId
Tied to a source, the chip takes that source's number, link, title and icon
(anything you pass wins), opens it in a new tab, and highlights with the
answer's markers for that source. Without citationId it is unchanged.
DOM contract
| Attribute | Where | Values |
|---|---|---|
data-slot | the marker, the preview | citation-marker, citation-preview, citation-sources, citation-mark-stack |
data-state | the marker | pending · resolved |
data-highlighted | the marker, a tied chip | present while its source is hovered or focused anywhere |
Numbering
A marker's number is its source's position in sources, after duplicate
ids are dropped (the first occurrence wins). Nothing is counted from the text,
so numbers cannot reshuffle as more of the answer arrives — as long as
sources only ever grows by appending, which is how a streamed array
arrives anyway. Reordering or removing an entry renumbers every marker after
it, and warns once in development.
useCitations() returns the numbered, deduplicated entries, for a source list
of your own:
function SourceList() {
const entries = useCitations()?.entries ?? [];
return entries.map((e) => <ChatBubbleSource key={e.source.id} citationId={e.source.id} />);
}The wire format
- Always the fragment form,
#cite:<id>. A barecite:scheme is not a safe protocol, and react-markdown rewrites it to an empty href — a link to the current page. - Ids are
A–Z a–z 0–9 _ . -. Other characters reach the marker percent-encoded; they are decoded, but keep ids plain. - Footnotes do not work. A
[^1]needs its[^1]: …definition, which arrives in a later block and is parsed on its own. - Without
citationComponents, a citation renders as an ordinary in-page link, so an answer stays readable in any markdown renderer.
Accessibility
- A marker is a link with a real name: "Source 3, example.com (opens in a new tab)", never a bare number. The visible number is part of the name.
- A pending marker is not a link. Without an
hrefan<a>is a generic element — no role, no tab stop, and no name (one is not allowed there). It becomes the link in place, the same element, when its source arrives, and while the answer streams nothing inside it is spoken (StreamingResponse keeps its content out of live regions). A citation whose source never arrives stays a plain chip that assistive technology skips. - The preview opens on keyboard focus as well as hover, and Escape closes
it without moving focus. Its title, domain and link are also in the marker's
name and the source list; a source's
descriptionappears only in the preview, so keep anything a reader must know out of it. - Touch: a tap opens the preview rather than following the link, so a mis-tap does not navigate away from an answer; the preview's title is the link. Every marker's touch target is 44px tall without changing the line height, and exactly as wide as the marker: a wider one would reach past the answer at the end of a line and scroll the page sideways. Across the line, a marker is an inline target in a sentence, which WCAG 2.5.8 exempts.
- Not a superscript. The number sits on the baseline at the meta size, 12px and up; a superscript of body text would be under the type floor.
- On a coloured plate the marker is the plate's soft chip, its highlight the plate's hairline, and its focus ring the plate's ink.
AT matrix: NOT yet screen-reader tested (hence beta). NVDA and JAWS:
untested, no Windows host (QA §5.6).