Elements

Sources

Runtime sources with favicon links for URLs and file badges for documents.

fig. 01

Installation

npx shadcn@latest add "@assistant-ui/sources"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Sources renders one citation from a message: a url source as a small link with a favicon and title, or a document source as a badge with a document icon. With a runtime it renders straight from a message's source part; standalone you hand it the part yourself. It comes in two designs: the runtime variant renders an inline link or badge next to the message text, and the static source-cards variant collapses every citation into one pill that expands into a grid of scannable cards (see The source-cards design).

Getting started

Render sources from a message

Register Sources as the Source renderer on MessagePrimitive.Parts. Every source part in the message routes through it automatically.

components/assistant-ui/elements/thread.aui.tsx
import { Sources } from "@/components/assistant-ui/elements/sources.aui";
import { MessagePrimitive } from "@assistant-ui/react";

function AssistantMessage() {
  return (
    <MessagePrimitive.Root>
      <MessagePrimitive.Parts components={{ Source: Sources }} />
    </MessagePrimitive.Root>
  );
}

A url part becomes a link, a document part becomes a badge, and any other shape renders nothing.

Anatomy

{/* sourceType: "url" */}
<a data-slot="source" href={url}>
  <img data-slot="source-icon" />
  <span data-slot="source-title">{title ?? domain}</span>
</a>

{/* sourceType: "document" */}
<span data-slot="source">
  <span data-slot="source-document-icon" />
  <span data-slot="source-title">{title}</span>
</span>

The favicon requests https://icons.duckduckgo.com/ip3/<domain>.ico by default; when that image fails to load, the icon falls back to a single letter drawn from the domain. The link's visible title is title when the source provides one, otherwise the bare domain with www. stripped. A document source never links anywhere and always shows its title. A url source with an empty url, or any other sourceType, renders nothing.

Examples

Custom favicon and variant

Sources itself takes only the part; to override the favicon lookup or restyle the link, compose the exported sub-parts into your own renderer and register that instead:

import { Sources } from "@/components/assistant-ui/elements/sources.aui";
import type { ComponentProps } from "react";

function CustomSource(part: ComponentProps<typeof Sources>) {
  if (part.sourceType !== "url" || !part.url) return null;
  return (
    <Sources.Root href={part.url} variant="secondary">
      <Sources.Icon
        url={part.url}
        faviconUrl={(domain) => `https://logo.clearbit.com/${domain}`}
      />
      <Sources.Title>{part.title}</Sources.Title>
    </Sources.Root>
  );
}

Use CustomSource in place of Sources in MessagePrimitive.Parts, or call it directly with a hand-built part.

Favicon fallback

When the favicon fails to load, Sources.Icon falls back to the domain's first letter. The demo points faviconUrl at a path that does not resolve:

Variants and sizes

Sources.Root carries the styling. Pass variant and size when you compose your own renderer as above. The Variants demo in the rail shows each variant.

VariantDescription
outlineBorder, transparent background (default)
secondarySolid secondary background
mutedMuted background
ghostNo background until hovered
infoBlue tint
warningAmber tint
successEmerald tint
destructiveRed tint
SizeDescription
smCompact padding
defaultStandard padding
lgLarger padding and text

API reference

Sources

ExportRendersNotes
Sourcesa or badgeThe Source message-part renderer. Pass to MessagePrimitive.Parts as components.Source.
Sources.RootaThe link for a url source. Accepts variant, size, href, and other anchor props.
Sources.Iconimg or fallback spanFavicon for a url source. Accepts url and an optional faviconUrl resolver.
Sources.TitlespanTruncated title or domain text, shared by both source shapes.

Source part

FieldTypeDescription
sourceType"url" | "document"Which shape to render.
idstringSource identifier.
urlstringLink target. Required and read when sourceType is "url".
titlestringOptional for "url" (falls back to the domain); required for "document".
mediaTypestringRequired when sourceType is "document". Not read by Sources.
filenamestringOptional, "document" only. Not read by Sources.

The source-cards design

The Static variant in the rail is a second design for the same citations: instead of an inline link or badge next to the message text, it collapses every citation into one pill that expands into a grid of cards, each with a domain avatar, the domain, and the title. It is a single props-driven component with no runtime dependency:

npx shadcn@latest add "@assistant-ui/elements-sources"

Nothing on the runtime collects a message's sources into one place: they arrive as individual source parts, so a selector gathers them into the shape this component expects.

components/assistant-ui/elements/message-sources.tsx
"use client";

import { useMemo, useState } from "react";
import { useAuiState } from "@assistant-ui/react";
import { Sources, type Source } from "@/components/assistant-ui/elements/sources";

function extractDomain(url: string) {
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

export function MessageSources() {
  const parts = useAuiState((s) => s.message.parts);
  const [open, setOpen] = useState(false);

  const sources = useMemo<Source[]>(
    () =>
      parts.flatMap((part) => {
        if (part.type !== "source" || part.sourceType !== "url") return [];
        const domain = extractDomain(part.url);
        return [{ domain, title: part.title ?? domain }];
      }),
    [parts],
  );

  if (sources.length === 0) return null;
  return <Sources sources={sources} open={open} onOpenChange={setOpen} />;
}

parts is read once and memoized before filtering, since useAuiState re-renders on every store update when its selector itself returns a new array. MessageSources must render inside a message scope, placed after MessagePrimitive.Parts in the assistant message.

Nothing inside the element opens or closes the panel: open and onOpenChange do. The trigger's count always reads sources.length, whether the panel is open or not; an empty sources array leaves the trigger reading "0" and the panel opens onto an empty grid.

The Sources component

PropTypeDefaultDescription
sourcesreadonly Source[]requiredThe full source list.
openbooleanrequiredWhether the card grid is expanded.
onOpenChange(open: boolean) => voidrequiredCalled when the trigger is clicked.
classNamestringMerged onto the root.

Source is { domain: string; title: string }. Unlike most elements in this catalog, this Sources does not spread extra props onto its root: only the four props above reach it.