AI Sources
The sources behind an answer, collapsed to a stack of favicons. Hover the stack and it fans apart as a hint that it is a set rather than a single badge; click and each chip travels into its own row in the expanded list.
Use it at the foot of an AI Message or an AI Response to close the loop between a claim and its evidence.
Usage
Pass sources as { id, title, url, snippet? }. Without a snippet the row
falls back to the host. Slot the favicon only if you have real marks —
otherwise you get the neutral globe, which is the honest default.
<template>
<div class="w-full max-w-lg rounded-2xl border border-default p-6">
<MAiSources :sources="sources">
<template #favicon="{ source }">
<UIcon :name="source.icon" />
</template>
</MAiSources>
</div>
</template>
<script setup lang="ts">
type Source = {
id: string
snippet?: string
title: string
url: string
icon: string
}
const sources: Source[] = [
{
id: 's-1',
icon: 'i-lucide-github',
snippet: 'Component: add / update / delete a source',
title: 'Add a search source',
url: 'https://ui.nuxt.com/docs/components/chat',
},
{
id: 's-2',
icon: 'i-lucide-book-open',
snippet: 'How retrieval-augmented generation is wired into the answer',
title: 'Retrieval notes',
url: 'https://ai-sdk.dev/docs',
},
{
id: 's-3',
icon: 'i-lucide-file-text',
snippet: 'Release checklist, internal',
title: 'Shipping checklist',
url: 'https://example.com',
},
{
id: 's-4',
icon: 'i-lucide-globe',
title: 'Token naming convention',
url: 'https://nuxt.com/docs',
},
]
</script>
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
sources | AiSource[] | - | Required. Each entry is { id, title, url, snippet? }. snippet is a short
excerpt or description; without it the row falls back to the bare host, parsed
from url with a leading www. stripped. An unparseable URL is displayed
verbatim rather than throwing. |
label | string | 'Sources' | The text after the stack. Set it to something countable — "4 sources" — if your
design needs the number spelled out; the stack itself only shows a +n chip
for the overflow. |
defaultOpen | boolean | false | Start expanded. Use it when the sources are the point of the surface rather than an appendix. |
class | string | '' | Merged onto the outer wrapper. |
Slots
| Slot | Props | Description |
|---|---|---|
favicon | { source: AiSource } | The mark for a source, rendered in both the collapsed stack and the expanded
row, and receiving the source it belongs to. Anything that fits an With no slot, each chip draws a neutral globe. The component never fabricates a one-letter badge from the host: an honest "unknown host" beats a convincing wrong answer pretending to be a logo. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiSources in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | w-full |
trigger | - | flex w-full cursor-pointer items-center gap-2 rounded-lg py-1 text-left text-sm text-muted transition-colors hover:text-default |
stack | - | flex items-center |
chip | - | ai-sources-chip relative block |
overflow | - | ml-1 tabular-nums |
chevron | - | flex size-4 items-center justify-center |
body | - | ai-sources-body |
list | - | mt-1 min-h-0 list-none overflow-hidden |
link | - | flex items-start gap-2.5 rounded-lg px-1 py-1.5 no-underline transition-colors hover:bg-muted |
favicon | - | mt-0.5 block shrink-0 |
rowText | - | min-w-0 |
rowTitle | - | block truncate text-sm font-medium text-default |
rowSnippet | - | block truncate text-xs text-muted |
faviconBase | - | flex items-center justify-center overflow-hidden rounded-full border border-default bg-muted text-muted |
faviconMark | - | flex size-full items-center justify-center *:size-full *:fill-current *:object-cover |
Accessibility
- The trigger is a native
<button type="button">carryingaria-expanded, so its state is announced rather than only drawn. - The collapsed stack shows at most three favicons and a
+ncount, so a long source list never becomes an unreadable ribbon. - The expanded list is a real
<ul>of<li>s, and every row is an anchor withtarget="_blank"andrel="noopener noreferrer". - The expanded body is
inertwhile collapsed. That removes it from the tab order and the accessibility tree entirely — a better guarantee than a collapsed container that still holds focusable links. - The fan is a hover hint only. It reveals nothing: the expanded list is what actually contains the sources, so keyboard users get everything a pointer user gets. Hovering the button is not what opens it.
- Titles and snippets truncate with
min-w-0, so a long source name ellipsises instead of pushing the row wide. - Under
prefers-reduced-motion: reducethe fan is suppressed, the expand is an instantgrid-template-rowsswitch with no opacity fade, and the rows appear already settled. - Nothing here is announced on expand — the list appearing is not an event. If a
reader needs to know how many sources an answer used, put that in the
label.
Performance
- The expand is a
grid-template-rowstransition from0frto1frwithmin-height: 0on the child, not an animatedheight: autoinside anAnimatePresence. Animating toautorequires measuring the content first, which is fragile and needs a second frame; the grid track resolves to the natural height with no measurement at all. - The chip stack and rows share a
layout-idper source, which is what makes a chip travel from the stack into its row. The stack side drops itslayout-idonce open, so both sides never claim the same element. - The layout prefix comes from
useId(), which is stable across SSR. A random suffix would differ between server and client and the shared layout animation would never match on hydration. - The fan is a CSS
transformtransition, not an animatedmargin-left. The chips are laid out at their fanned spacing, so the resting overlap is the transform's negative offset. Animating a layout property would re-run the shared-layout-idFLIP on every hover and fight the transition, and even a plainmargin-lefttransition reflows the row and drags the label beside the stack. A transform changes only what is painted, so nothing reflows. - Only the first three sources render a chip. Overflow is a single computed subtraction, so a fifty-source answer costs the same DOM as a three-source one.
- Row transitions are staggered by index on open and share one frozen transition object per index.