Inline chip for showing a source reference with hover preview (favicon, label, and card copy for title, description, and link). Pass citations as an array of { url, title?, description? }—one entry gives a single citation; several entries pair with CitationCarousel and CitationCarouselItem so users can move between sources in the same preview.
"use client";
import {
Citation,
CitationContent,
CitationItem,
CitationTrigger,
} from "@/components/nexus-ui/citation";
const SOURCES = [
{
url: "https://en.wikipedia.org/wiki/List_of_African_countries_by_area",
title: "List of African countries by area",
description:
"Africa is the second-largest continent in the world by area and population. Algeria has been the largest country in Africa and the Arab world since the division ...Read more",
},
{
url: "https://www.worldometers.info/population/countries-in-africa-by-population/",
title: "African Countries by Population (2026)",
description:
"List of countries (or dependencies) in Africa ranked by population, from the most populated. Growth rate, median age, fertility rate, area, density, population density, urbanization, urban population, share of world population.",
},
{
url: "https://developer.mozilla.org/en-US/docs/Web/CSS/flex",
title: "flex",
description:
"The flex CSS shorthand property sets how a flex item will grow or shrink to fit the space available in its flex container.",
},
{
url: "https://github.com/vercel/ai",
title: "Vercel AI SDK",
description:
"The AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications.",
},
] as const;
function SingleCitation({
source,
}: {
source: (typeof SOURCES)[number];
}) {
return (
<Citation citations={[source]}>
<CitationTrigger />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
);
}
function CitationDefault() {
return (
<div className="flex flex-wrap items-center gap-3">
{SOURCES.map((source) => (
<SingleCitation key={source.url} source={source} />
))}
</div>
);
}
export default CitationDefault;
Installation
npx shadcn@latest add @nexus-ui/citationpnpm dlx shadcn@latest add @nexus-ui/citationyarn dlx shadcn@latest add @nexus-ui/citationbunx shadcn@latest add @nexus-ui/citationCopy and paste the following code into your project.
"use client";
import * as React from "react";
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from "@/components/ui/carousel";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
/** One source: `url` plus `title` / `description` from your pipeline. */
export type CitationSourceInput = {
url: string;
title?: React.ReactNode;
description?: React.ReactNode;
};
/** Normalized citation for primitives (context / item scope). */
export type ResolvedCitation = {
url: string;
title: React.ReactNode | null;
description: React.ReactNode | null;
siteName: string;
faviconSrc: string;
};
export function parseCitationUrl(urlStr: string): URL {
const trimmed = urlStr.trim();
try {
return new URL(trimmed);
} catch {
return new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
}
}
function titleCaseLabel(label: string): string {
if (!label) return label;
const lower = label.toLowerCase();
return lower.slice(0, 1).toUpperCase() + lower.slice(1);
}
/**
* Label before the last DNS segment for display, e.g. `en.wikipedia.org` → `Wikipedia`.
* Not a full registrable-domain parse: `bbc.co.uk` → `Co` (wrong). Use a PSL library if you need that.
*/
export function rootDomainSiteName(url: URL): string {
const host = url.hostname.replace(/^www\./i, "").toLowerCase();
const segments = host.split(".").filter(Boolean);
if (segments.length === 0) return "";
const label =
segments.length >= 2 ? segments[segments.length - 2]! : segments[0]!;
return titleCaseLabel(label);
}
function hasCitationField(v: unknown): boolean {
return (
v !== undefined && v !== null && !(typeof v === "string" && v.trim() === "")
);
}
export function resolveCitationSource(
input: CitationSourceInput,
): ResolvedCitation {
const parsed = parseCitationUrl(input.url);
const siteName = rootDomainSiteName(parsed);
const faviconSrc = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(parsed.hostname)}&sz=64`;
return {
url: parsed.href,
title: hasCitationField(input.title) ? input.title! : null,
description: hasCitationField(input.description)
? input.description!
: null,
siteName,
faviconSrc,
};
}
export function resolveCitationSources(
inputs: CitationSourceInput[],
): ResolvedCitation[] {
return inputs.map(resolveCitationSource);
}
type CitationRootContextValue = {
citations: ResolvedCitation[];
activeIndex: number;
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
carouselApi: CarouselApi | null;
setCarouselApi: (api: CarouselApi | undefined) => void;
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
carouselCurrent: number;
carouselCount: number;
};
const CitationRootContext =
React.createContext<CitationRootContextValue | null>(null);
const CitationItemContext = React.createContext<ResolvedCitation | null>(null);
function useCitationRoot(component: string): CitationRootContextValue {
const ctx = React.useContext(CitationRootContext);
if (!ctx) {
throw new Error(`${component} must be used within Citation`);
}
return ctx;
}
function useResolvedCitation(component: string): ResolvedCitation {
const item = React.useContext(CitationItemContext);
const root = useCitationRoot(component);
const idx = Math.min(
Math.max(0, root.activeIndex),
Math.max(0, root.citations.length - 1),
);
const fromRoot = root.citations[idx];
const resolved = item ?? fromRoot;
if (!resolved) {
throw new Error(`${component}: no citation for this scope`);
}
return resolved;
}
export type CitationProps = Omit<
React.ComponentProps<typeof HoverCard>,
"children"
> & {
citations: CitationSourceInput[];
children?: React.ReactNode;
};
function Citation({
citations: citationInputs,
children,
...hoverCardProps
}: CitationProps) {
const resolved = React.useMemo(
() => resolveCitationSources(citationInputs),
[citationInputs],
);
const [activeIndex, setActiveIndex] = React.useState(0);
const [carouselApi, setCarouselApi] = React.useState<CarouselApi | null>(
null,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const [carouselCurrent, setCarouselCurrent] = React.useState(1);
const [carouselCount, setCarouselCount] = React.useState(0);
const len = resolved.length;
React.useEffect(() => {
setActiveIndex((i) => (len === 0 ? 0 : Math.min(Math.max(0, i), len - 1)));
}, [len]);
React.useEffect(() => {
if (!carouselApi) {
setCanScrollPrev(false);
setCanScrollNext(false);
setCarouselCount(0);
setCarouselCurrent(1);
return;
}
const sync = () => {
setActiveIndex(carouselApi.selectedScrollSnap());
setCanScrollPrev(carouselApi.canScrollPrev());
setCanScrollNext(carouselApi.canScrollNext());
setCarouselCount(carouselApi.scrollSnapList().length);
setCarouselCurrent(carouselApi.selectedScrollSnap() + 1);
};
sync();
carouselApi.on("select", sync);
carouselApi.on("reInit", sync);
return () => {
carouselApi.off("select", sync);
carouselApi.off("reInit", sync);
};
}, [carouselApi]);
const setCarouselApiCb = React.useCallback((api: CarouselApi | undefined) => {
setCarouselApi(api ?? null);
}, []);
const scrollPrev = React.useCallback(() => {
carouselApi?.scrollPrev();
}, [carouselApi]);
const scrollNext = React.useCallback(() => {
carouselApi?.scrollNext();
}, [carouselApi]);
const value = React.useMemo<CitationRootContextValue>(
() => ({
citations: resolved,
activeIndex,
setActiveIndex,
carouselApi,
setCarouselApi: setCarouselApiCb,
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
carouselCurrent,
carouselCount,
}),
[
resolved,
activeIndex,
carouselApi,
setCarouselApiCb,
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
carouselCurrent,
carouselCount,
],
);
if (len === 0) {
return null;
}
return (
<CitationRootContext.Provider value={value}>
<HoverCard
data-slot="citation"
{...hoverCardProps}
openDelay={50}
closeDelay={50}
>
{children}
</HoverCard>
</CitationRootContext.Provider>
);
}
export type CitationTriggerProps = Omit<
React.ComponentProps<typeof HoverCardTrigger>,
"children"
> & {
/** Replaces the default site name text; still respects `showFavicon` / `showSiteName`. */
label?: React.ReactNode;
showFavicon?: boolean;
showSiteName?: boolean;
};
function CitationTrigger({
className,
label,
showFavicon = true,
showSiteName = true,
...props
}: CitationTriggerProps) {
const root = useCitationRoot("CitationTrigger");
const c = root.citations[0]!;
let text: React.ReactNode =
label !== undefined && label !== null
? label
: showSiteName
? c.siteName
: null;
let hasText =
text !== null &&
text !== undefined &&
!(typeof text === "string" && text.trim() === "");
if (!showFavicon && !hasText) {
text = "Source";
hasText = true;
}
const baseClassName = cn(
"inline-flex h-6 max-w-full cursor-default items-center rounded-full bg-secondary opacity-100 transition-colors hover:bg-border data-[state=open]:opacity-100 align-middle",
hasText && showFavicon && "gap-1 py-1 pr-2 pl-1",
hasText && !showFavicon && "px-2 py-1",
!hasText && showFavicon && "p-1",
);
const multipleSources = root.citations.length > 1;
const chipBody = (
<>
{showFavicon ? (
<CitationFavicon src={c.faviconSrc} />
) : null}
{hasText ? <CitationSiteName>{text}</CitationSiteName> : null}
{multipleSources && (
<span
data-slot="citation-extra-count"
className="text-xs leading-4.5 font-[350] text-muted-foreground tabular-nums"
>
+{root.citations.length - 1}
</span>
)}
</>
);
return (
<HoverCardTrigger data-slot="citation-trigger" asChild {...props}>
{multipleSources ? (
<span className={cn(baseClassName, className)}>{chipBody}</span>
) : (
<a
href={c.url}
target="_blank"
rel="noreferrer"
className={cn(baseClassName, className)}
>
{chipBody}
</a>
)}
</HoverCardTrigger>
);
}
export type CitationContentProps = React.ComponentProps<
typeof HoverCardContent
>;
function CitationContent({
className,
align = "center",
sideOffset = 4,
...props
}: CitationContentProps) {
return (
<HoverCardContent
data-slot="citation-content"
align={align}
sideOffset={sideOffset}
className={cn(
"flex w-66 flex-col overflow-hidden rounded-[12px] border-border p-0 shadow-modal dark:border-accent",
className,
)}
{...props}
/>
);
}
type CitationCarouselProps = React.ComponentProps<typeof Carousel>;
function CitationCarousel({
setApi: setApiProp,
...props
}: CitationCarouselProps) {
const { setCarouselApi } = useCitationRoot("CitationCarousel");
return (
<Carousel
data-slot="citation-carousel"
setApi={(api) => {
setCarouselApi(api);
setApiProp?.(api);
}}
{...props}
/>
);
}
type CitationCarouselHeaderProps = React.ComponentProps<"div">;
function CitationCarouselHeader({
className,
...props
}: CitationCarouselHeaderProps) {
return (
<div
data-slot="citation-carousel-header"
className={cn(
"flex h-auto w-full items-center justify-between px-3 pt-3",
className,
)}
{...props}
/>
);
}
/** Horizontal flex track height = max(slides); shrink viewport to the active slide. */
function useCarouselViewportHeight(
wrapRef: React.RefObject<HTMLDivElement | null>,
carouselApi: CarouselApi | null,
activeIndex: number,
) {
React.useLayoutEffect(() => {
const vp = wrapRef.current?.querySelector<HTMLElement>(
"[data-slot=carousel-content]",
);
if (!vp) return;
const clear = () => {
vp.style.height = "";
vp.style.transition = "";
};
if (!carouselApi) return clear();
vp.style.transition = "height 500ms ease-out";
const sync = () => {
const h = carouselApi.slideNodes()[activeIndex]?.offsetHeight ?? 0;
vp.style.height = h > 0 ? `${h}px` : "";
};
sync();
const slide = carouselApi.slideNodes()[activeIndex];
if (!slide) return clear;
const ro = new ResizeObserver(sync);
ro.observe(slide);
return () => (ro.disconnect(), clear());
}, [carouselApi, activeIndex]);
}
type CitationCarouselContentProps = React.ComponentProps<
typeof CarouselContent
>;
function CitationCarouselContent({
className,
...props
}: CitationCarouselContentProps) {
const { carouselApi, activeIndex } = useCitationRoot(
"CitationCarouselContent",
);
const wrapRef = React.useRef<HTMLDivElement>(null);
useCarouselViewportHeight(wrapRef, carouselApi, activeIndex);
return (
<div ref={wrapRef} className="contents">
<CarouselContent
data-slot="citation-carousel-content"
className={className}
{...props}
/>
</div>
);
}
type CitationCarouselItemProps = React.ComponentProps<typeof CarouselItem> & {
index: number;
};
function CitationCarouselItem({
index,
className,
children,
...props
}: CitationCarouselItemProps) {
const { citations } = useCitationRoot("CitationCarouselItem");
const item = citations[index];
if (!item) return null;
return (
<CarouselItem
data-slot="citation-carousel-item"
className={cn("self-start", className)}
{...props}
>
<CitationItemContext.Provider value={item}>
{children}
</CitationItemContext.Provider>
</CarouselItem>
);
}
type CitationCarouselPaginationProps = React.ComponentProps<"div">;
function CitationCarouselPagination({
className,
...props
}: CitationCarouselPaginationProps) {
return (
<div
data-slot="citation-carousel-pagination"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
type CitationCarouselNavButtonProps =
React.ButtonHTMLAttributes<HTMLButtonElement>;
function CitationCarouselPrev({
className,
children,
...props
}: CitationCarouselNavButtonProps) {
const { scrollPrev, canScrollPrev } = useCitationRoot("CitationCarouselPrev");
return (
<button
type="button"
data-slot="citation-carousel-prev"
disabled={!canScrollPrev}
className={cn(
"flex size-6.5 cursor-pointer items-center justify-center rounded-full text-primary outline-0 transition-all hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-accent/50",
className,
)}
onClick={scrollPrev}
{...props}
>
{children ?? (
<HugeiconsIcon
icon={ArrowLeft01Icon}
strokeWidth={2}
className="size-4"
/>
)}
</button>
);
}
function CitationCarouselNext({
className,
children,
...props
}: CitationCarouselNavButtonProps) {
const { scrollNext, canScrollNext } = useCitationRoot("CitationCarouselNext");
return (
<button
type="button"
data-slot="citation-carousel-next"
disabled={!canScrollNext}
className={cn(
"flex size-6.5 cursor-pointer items-center justify-center rounded-full text-primary outline-0 transition-all hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-accent/50",
className,
)}
onClick={scrollNext}
{...props}
>
{children ?? (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className="size-4"
/>
)}
</button>
);
}
type CitationCarouselIndexProps = React.HTMLAttributes<HTMLSpanElement>;
function CitationCarouselIndex({
className,
...props
}: CitationCarouselIndexProps) {
const { carouselCurrent, carouselCount } = useCitationRoot(
"CitationCarouselIndex",
);
return (
<span
data-slot="citation-carousel-index"
className={cn(
"text-xs leading-4.5 font-[350] text-muted-foreground tabular-nums",
className,
)}
{...props}
>
{carouselCurrent}/{carouselCount}
</span>
);
}
type CitationFaviconGroupProps = React.ComponentProps<"div">;
function CitationFaviconGroup({
className,
children,
...props
}: CitationFaviconGroupProps) {
const { citations } = useCitationRoot("CitationFaviconGroup");
return (
<div
data-slot="citation-favicon-group"
className={cn(
"flex -space-x-2 *:data-[slot=citation-favicon]:ring-2 *:data-[slot=citation-favicon]:ring-secondary",
className,
)}
{...props}
>
{children ??
citations.map((citation, i) => (
<CitationFavicon
key={citation.url + i}
src={citation.faviconSrc}
/>
))}
</div>
);
}
export type CitationSourcesBadgeProps = Omit<
React.ComponentPropsWithoutRef<"div">,
"children"
> & {
/** Include the overlapping favicon stack. Default: true. */
showFavicons?: boolean;
/** Override the trailing label; default is "{n} source(s)" from `citations`. */
label?: React.ReactNode;
};
/** Stacked favicons + count label for headers, message actions, etc. Must be inside `Citation`. */
function CitationSourcesBadge({
className,
showFavicons = true,
label,
...props
}: CitationSourcesBadgeProps) {
const { citations } = useCitationRoot("CitationSourcesBadge");
const count = citations.length;
const text =
label ?? `${count} ${count === 1 ? "source" : "sources"}`;
return (
<div
data-slot="citation-sources-badge"
className={cn(
"mt-0 flex h-6.5 items-center gap-1.5 rounded-full bg-secondary pr-1.5",
showFavicons ? "pl-1" : "pl-1.5",
className,
)}
{...props}
>
{showFavicons ? (
<div
data-slot="citation-favicon-group"
className="flex -space-x-2 *:data-[slot=citation-favicon]:ring-2 *:data-[slot=citation-favicon]:ring-secondary"
>
{citations.map((citation, i) => (
<CitationFavicon key={citation.url + i} src={citation.faviconSrc} />
))}
</div>
) : null}
<span className="text-xs leading-4.5 font-normal text-primary">
{text}
</span>
</div>
);
}
export type CitationItemProps = React.ComponentPropsWithoutRef<"a"> & {
/** Include the default title (`h4`). Default: true. */
showTitle?: boolean;
/** Include the default description (`p`). Default: true. */
showDescription?: boolean;
/** Include the default footer row (`CitationSource`). Default: true. */
showSource?: boolean;
};
function CitationItem({
className,
children,
href,
showTitle = true,
showDescription = true,
showSource = true,
...props
}: CitationItemProps) {
const c = useResolvedCitation("CitationItem");
const defaultContent = (
<>
{showTitle && c.title != null ? (
<h4
data-slot="citation-title"
className="line-clamp-2 text-xs leading-4 font-[450] text-primary"
>
{c.title}
</h4>
) : null}
{showDescription && c.description != null ? (
<p
data-slot="citation-description"
className="line-clamp-3 text-xs leading-4.5 font-[350] text-muted-foreground"
>
{c.description}
</p>
) : null}
{showSource ? <CitationSource /> : null}
</>
);
return (
<a
data-slot="citation-item"
className={cn(
"flex w-full flex-col gap-1 p-3 text-start no-underline outline-none",
className,
)}
href={href ?? c.url}
target="_blank"
rel="noreferrer"
{...props}
>
{children ?? defaultContent}
</a>
);
}
type CitationSourceProps = React.HTMLAttributes<HTMLDivElement>;
function CitationSource({
className,
children,
...props
}: CitationSourceProps) {
return (
<div
data-slot="citation-source"
className={cn("mt-2 flex items-center gap-1.5", className)}
{...props}
>
{children ?? (
<>
<CitationFavicon />
<CitationSiteName />
</>
)}
</div>
);
}
type CitationFaviconProps = Omit<
React.HTMLAttributes<HTMLDivElement>,
"children"
> & {
src?: string;
};
function CitationFavicon({
className,
src,
...props
}: CitationFaviconProps) {
const c = useResolvedCitation("CitationFavicon");
const resolvedSrc = src ?? c.faviconSrc;
if (resolvedSrc === "") return null;
return (
<img
src={resolvedSrc}
alt=""
data-slot="citation-favicon"
className={cn(
"size-4 shrink-0 rounded-full bg-background",
className,
)}
{...props}
/>
);
}
function CitationSiteName({
className,
children,
...props
}: React.HTMLAttributes<HTMLSpanElement>) {
const c = useResolvedCitation("CitationSiteName");
const content = children ?? c.siteName;
if (content == null) return null;
return (
<span
data-slot="citation-site-name"
className={cn(
"text-xs leading-4.5 font-normal text-primary",
className,
)}
{...props}
>
{content}
</span>
);
}
export {
Citation,
CitationCarousel,
CitationCarouselContent,
CitationCarouselHeader,
CitationCarouselIndex,
CitationCarouselItem,
CitationCarouselNext,
CitationCarouselPagination,
CitationCarouselPrev,
CitationContent,
CitationFavicon,
CitationFaviconGroup,
CitationItem,
CitationSourcesBadge,
CitationSiteName,
CitationSource,
CitationTrigger,
};
Install registry dependencies: @nexus-ui/nexus-ui-theme, carousel, and hover-card (or use the CLI so they install automatically).
Update the import paths to match your project setup.
Usage
import {
Citation,
CitationContent,
CitationItem,
CitationTrigger,
} from "@/components/nexus-ui/citation";<Citation citations={[{ url: "https://example.com", title: "Example", description: "…" }]}>
<CitationTrigger />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>CitationItem renders a default h4 title, p description, and CitationSource footer. Toggle blocks with showTitle, showDescription, and showSource, or pass children to replace the default layout entirely (e.g. custom order or CitationSource only).
Examples
Multiple sources
With more than one citations entry, the default chip shows +N after the first source. CitationCarousel wraps a shadcn Carousel component inside the hover card so each source is one slide—users move between them with prev/next (or swipe) without closing the preview.
"use client";
import {
Citation,
CitationCarousel,
CitationCarouselContent,
CitationCarouselHeader,
CitationCarouselIndex,
CitationCarouselItem,
CitationCarouselNext,
CitationCarouselPagination,
CitationCarouselPrev,
CitationContent,
CitationItem,
CitationSourcesBadge,
CitationTrigger,
} from "@/components/nexus-ui/citation";
type Source = {
url: string;
title: string;
description: string;
};
const GROUP_A: Source[] = [
{
url: "https://en.wikipedia.org/wiki/List_of_African_countries_by_area",
title: "List of African countries by area",
description:
"Africa is the second-largest continent in the world by area and population. Algeria has been the largest country in Africa and the Arab world since the division ...Read more",
},
{
url: "https://www.worldometers.info/population/countries-in-africa-by-population/",
title: "African Countries by Population (2026)",
description:
"List of countries (or dependencies) in Africa ranked by population, from the most populated. Growth rate, median age, fertility rate, area, density, population density, urbanization, urban population, share of world population.",
},
{
url: "https://dabafinance.com/en/insights/top-10-largest-african-economies-by-gdp-in-2026",
title: "Top 10 Largest African Economies by GDP in 2026",
description:
"When discussing African economies its easy to confuse which countries are growing fastest with which economies are actually largest These are two very different measurements and both mat...",
},
];
const GROUP_B: Source[] = [
{
url: "https://developer.mozilla.org/en-US/docs/Web/CSS/flex",
title: "flex",
description:
"The flex CSS shorthand property sets how a flex item will grow or shrink to fit the space available in its flex container.",
},
{
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/article",
title: "<article>: The Article Contents element",
description:
"The <article> HTML element represents a self-contained composition in a document, page, application, or site, which is intended to be independently distributable or reusable.",
},
];
const GROUP_C: Source[] = [
{
url: "https://github.com/vercel/ai",
title: "Vercel AI SDK",
description:
"The AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications.",
},
{
url: "https://github.com/openai/openai-node",
title: "openai-node",
description:
"Official JavaScript / TypeScript library for the OpenAI API. It runs on Node.js and the web.",
},
{
url: "https://github.com/anthropics/anthropic-sdk-typescript",
title: "anthropic-sdk-typescript",
description:
"A TypeScript library providing convenient access to the Anthropic REST API.",
},
];
const GROUP_D: Source[] = [
{
url: "https://news.ycombinator.com/",
title: "Hacker News",
description:
"Hacker News is a social news website focusing on computer science and entrepreneurship.",
},
{
url: "https://www.reuters.com/",
title: "Reuters",
description:
"Reuters is a global news agency providing business, financial, and international news.",
},
{
url: "https://www.bbc.com/news",
title: "BBC News",
description:
"BBC News provides breaking news, video, and analysis from the UK and around the world.",
},
];
const BUNDLES = [GROUP_A, GROUP_B, GROUP_C, GROUP_D];
function MultiSourceCitation({ items }: { items: Source[] }) {
return (
<Citation citations={items}>
<CitationTrigger />
<CitationContent>
<CitationCarousel className="flex w-full flex-col">
<CitationCarouselHeader>
<CitationSourcesBadge />
<CitationCarouselPagination>
<CitationCarouselPrev />
<CitationCarouselIndex />
<CitationCarouselNext />
</CitationCarouselPagination>
</CitationCarouselHeader>
<CitationCarouselContent>
{items.map((_, index) => (
<CitationCarouselItem key={items[index].url} index={index}>
<CitationItem />
</CitationCarouselItem>
))}
</CitationCarouselContent>
</CitationCarousel>
</CitationContent>
</Citation>
);
}
function CitationMultipleSources() {
return (
<div className="flex flex-wrap items-center gap-3">
{BUNDLES.map((items) => (
<MultiSourceCitation key={items[0].url} items={items} />
))}
</div>
);
}
export default CitationMultipleSources;
Trigger label and favicon
The built-in CitationTrigger chip can be adjusted with showFavicon and showSiteName. Use label to replace the auto-derived site name with any string—numbers, hostname, or text from resolveCitationSource / parseCitationUrl when you want it derived in code.
"use client";
import {
Citation,
CitationContent,
CitationItem,
parseCitationUrl,
CitationTrigger,
resolveCitationSource,
} from "@/components/nexus-ui/citation";
const SOURCE = {
url: "https://github.com/victorcodess/nexus-ui",
title: "victorcodess/nexus-ui",
description: "Beautiful, customizable components for modern AI experiences.",
};
const resolved = resolveCitationSource(SOURCE);
const hostname = parseCitationUrl(SOURCE.url).hostname;
function CitationTriggerVariants() {
return (
<div className="flex flex-wrap items-center gap-3">
<Citation citations={[SOURCE]}>
<CitationTrigger showSiteName={false} />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
<Citation citations={[SOURCE]}>
<CitationTrigger showFavicon={false} label="2" />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
<Citation citations={[SOURCE]}>
<CitationTrigger showFavicon={false} label={hostname} />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
<Citation citations={[SOURCE]}>
<CitationTrigger showFavicon={false} label={resolved.siteName} />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
</div>
);
}
export default CitationTriggerVariants;
Inline with text
The trigger can sit inline with surrounding copy—place Citation where a phrase or sentence needs a source chip.
Algeria often leads land-area lists for Africa. Headlines still blur the two questions when editors rush copy. Population rankings reshuffle the order—see the full country table. Worldometers
Dense dashboards punish vague flex values. Mozilla Tuning a flex row is easier when the shorthand is one click away: check before you lock in grow and shrink.
Prototype streaming before you polish the UI shell. A tiny route in Next.js usually starts with the toolkit README and examples. Github
"use client";
import {
Citation,
CitationContent,
CitationItem,
CitationTrigger,
} from "@/components/nexus-ui/citation";
const WORLD_POP = {
url: "https://www.worldometers.info/population/countries-in-africa-by-population/",
title: "African Countries by Population (2026)",
description:
"List of countries (or dependencies) in Africa ranked by population, from the most populated. Growth rate, median age, fertility rate, area, density, population density, urbanization, urban population, share of world population.",
};
const MDN_FLEX = {
url: "https://developer.mozilla.org/en-US/docs/Web/CSS/flex",
title: "flex",
description:
"The flex CSS shorthand property sets how a flex item will grow or shrink to fit the space available in its flex container.",
};
const VERCEL_AI = {
url: "https://github.com/vercel/ai",
title: "Vercel AI SDK",
description:
"The AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications.",
};
type Source = typeof WORLD_POP;
function InlineCitation({ source }: { source: Source }) {
return (
<Citation citations={[source]}>
<CitationTrigger />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
);
}
function CitationInlineWithText() {
return (
<div className="prose max-w-prose text-sm leading-relaxed text-foreground">
<p className="mb-4 first:mt-0 last:mb-0">
Algeria often leads land-area lists for Africa. Headlines still blur the
two questions when editors rush copy. Population rankings reshuffle the
order—see the full country table. <InlineCitation source={WORLD_POP} />
</p>
<p className="mb-4 last:mb-0">
Dense dashboards punish vague flex values.{" "}
<InlineCitation source={MDN_FLEX} /> Tuning a flex row is easier when
the shorthand is one click away: check before you lock in grow and
shrink.
</p>
<p className="mb-4 last:mb-0">
Prototype streaming before you polish the UI shell. A tiny route in
Next.js usually starts with the toolkit README and examples.{" "}
<InlineCitation source={VERCEL_AI} />
</p>
</div>
);
}
export default CitationInlineWithText;
Vercel AI SDK Integration
The Vercel AI SDK does not define a single standard for inline citations in chat streams—models are not required to emit a shared citation syntax, and there is no built-in “citation” channel you can rely on for every provider. Streamdown also has no official inline-citation or footnote-to-chip resolution; you have to wire sources yourself.
Citation takes citations: CitationSourceInput[] only. In practice, you can fill that array from (1) a structured object stream (streamObject + experimental_useObject) with a Zod schema, or (2) assistant UIMessage parts of type: "source-url" from useChat when your POST /api/chat response actually includes them (toUIMessageStreamResponse({ sendSources: true }) and a model or provider that emits source parts—many completions only stream text).
Structured object stream
Install the AI SDK and Zod
npm install ai @ai-sdk/react @ai-sdk/openai zodAdd the citation API route
Define the Zod schema in this file and export it so the client can pass the same shape to useObject.
import { streamObject, zodSchema } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
export const citationSchema = z.object({
content: z.string(),
citations: z.array(
z.object({
number: z.string(),
title: z.string(),
url: z.string(),
description: z.string().optional(),
}),
),
});
export const maxDuration = 30;
export async function POST(req: Request) {
const { prompt } = (await req.json()) as { prompt: string };
const result = streamObject({
model: openai("gpt-4o"),
schema: zodSchema(citationSchema),
prompt: `Generate a well-researched paragraph about ${prompt} with proper citations.
Include:
- A comprehensive paragraph with inline citations marked as [1], [2], etc.
- 2-3 citations with realistic source information
- Each citation should have a title, URL, and optional description
- Make the content informative and the sources credible
Format citations as numbered references within the text.`,
});
return result.toTextStreamResponse();
}Add a client page for structured citations
"use client";
import { experimental_useObject as useObject } from "@ai-sdk/react";
import { Button } from "@/components/ui/button";
import {
Citation,
CitationContent,
CitationItem,
CitationTrigger,
} from "@/components/nexus-ui/citation";
import { citationSchema } from "@/app/api/citation/route";
export default function CitationsObjectPage() {
const { object, submit, isLoading } = useObject({
api: "/api/citation",
schema: citationSchema,
});
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() => submit({ prompt: "how tidal power works" })}
disabled={isLoading}
variant="outline"
>
Tidal energy
</Button>
<Button
type="button"
onClick={() =>
submit({ prompt: "the history of the printing press in Europe" })
}
disabled={isLoading}
variant="outline"
>
Printing history
</Button>
</div>
{isLoading && !object ? (
<p className="text-muted-foreground text-sm">
Generating content with citations…
</p>
) : null}
{object?.content ? (
<div className="prose prose-sm max-w-none">
<p className="leading-relaxed">
{object.content.split(/(\[\d+\])/).map((part, index) => {
const citationMatch = part.match(/\[(\d+)\]/);
if (citationMatch) {
const citationNumber = citationMatch[1];
const row = object.citations?.find(
(c) => c.number === citationNumber,
);
if (row) {
return (
<Citation
key={index}
citations={[
{
url: row.url,
title: row.title,
description: row.description,
},
]}
>
<CitationTrigger />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
);
}
}
return part;
})}
</p>
</div>
) : null}
</div>
);
}Chat stream with source-url parts
Install the AI SDK
npm install ai @ai-sdk/react @ai-sdk/openaiAdd the chat API route
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { openai } from "@ai-sdk/openai";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai("gpt-4o-mini"),
system:
"You are a helpful assistant. When you cite web sources, include URLs in your answer where applicable.",
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse({
sendSources: true,
});
}Add a client page for chat citations
"use client";
import { useCallback, useMemo, useState } from "react";
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
isTextUIPart,
type UIMessage,
} from "ai";
import { Button } from "@/components/ui/button";
import {
Citation,
CitationContent,
CitationItem,
CitationTrigger,
type CitationSourceInput,
} from "@/components/nexus-ui/citation";
function textFromMessage(message: UIMessage) {
return message.parts.filter(isTextUIPart).map((p) => p.text).join("");
}
function citationsFromSourceUrlParts(
message: UIMessage,
): CitationSourceInput[] {
return message.parts
.filter(
(p): p is Extract<UIMessage["parts"][number], { type: "source-url" }> =>
p.type === "source-url",
)
.map((p) => ({
url: p.url,
title: p.title,
}));
}
export default function CitationsChatPage() {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
const lastAssistant = useMemo(
() => [...messages].reverse().find((m) => m.role === "assistant"),
[messages],
);
const citations = lastAssistant
? citationsFromSourceUrlParts(lastAssistant)
: [];
const assistantText = lastAssistant ? textFromMessage(lastAssistant) : "";
const isBusy = status === "submitted" || status === "streaming";
const handleSubmit = useCallback(() => {
const t = input.trim();
if (!t) return;
sendMessage({ text: t });
setInput("");
}, [input, sendMessage]);
return (
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="min-h-[80px] w-full rounded-md border border-border bg-background p-2 text-sm"
placeholder="Ask something…"
/>
<Button type="button" onClick={handleSubmit} disabled={isBusy}>
Send
</Button>
{assistantText ? (
<p className="text-sm leading-relaxed">{assistantText}</p>
) : null}
{citations.length > 0 ? (
<Citation citations={citations}>
<CitationTrigger />
<CitationContent>
<CitationItem />
</CitationContent>
</Citation>
) : null}
</div>
);
}API Reference
Citation
Root component. Normalizes citations, stores citation source data and carousel state in context for descendants, and wraps Hover Card.
Prop
Type
CitationTrigger
The source chip users see in the UI. Wraps Hover Card Trigger so hovering it opens the preview (CitationContent). Adjust the chip with label, showFavicon, and showSiteName.
Prop
Type
CitationContent
Preview card that appears when the trigger is hovered and shows the source details; Wraps Hover Card Content.
Prop
Type
CitationCarousel
Carousel root for multi-source slides. setApi registers the API on Citation (for nav/index) and invokes any setApi you pass.
Prop
Type
CitationCarouselHeader
Header row above the slide viewport; Usually contains a summary CitationSource and CitationCarouselPagination.
Prop
Type
CitationCarouselContent
Wraps Carousel Content and syncs viewport height to the active slide.
Prop
Type
CitationCarouselItem
One carousel slide; index selects citations[index] and sets CitationItemContext. Wraps Carousel Item.
Prop
Type
CitationCarouselPagination
Flex row for nav controls.
Prop
Type
CitationCarouselPrev / CitationCarouselNext
Icon buttons for prev/next navigation; Standard button HTML attributes.
Prop
Type
CitationCarouselIndex
current / count from context (1-based current). span HTML attributes.
Prop
Type
CitationItem
Preview row as an <a>, containing the source details; href defaults to the citation URL. Default stack: h4 title, p description, CitationSource footer.
Prop
Type
CitationSource
Citation source chip with the favicon and site name.
Prop
Type
CitationFavicon
Favicon of the citation site. src overrides faviconSrc from context.
Prop
Type
CitationSiteName
Site name of the citation site.
Prop
Type
CitationSourcesBadge
Chip with an optional overlapping favicon stack plus a {n} source(s) label. Use in CitationCarouselHeader, message action rows, or anywhere inside Citation.
Prop
Type
parseCitationUrl
Tolerant parse to a URL: trims the string and, when no http/https scheme is present, prefixes https:// so bare hostnames resolve. Use for hostname extraction, favicon lookups, or custom CitationTrigger labels outside the component tree.
export function parseCitationUrl(urlStr: string): URL;resolveCitationSource
Maps one CitationSourceInput to ResolvedCitation—normalized url, optional title / description, derived siteName, and a faviconSrc URL. resolveCitationSources maps an array; Citation uses this pipeline when resolving the citations prop.
export type CitationSourceInput = {
url: string;
title?: React.ReactNode;
description?: React.ReactNode;
};
export type ResolvedCitation = {
url: string;
title: React.ReactNode | null;
description: React.ReactNode | null;
siteName: string;
faviconSrc: string;
};
export function resolveCitationSource(
input: CitationSourceInput,
): ResolvedCitation;
export function resolveCitationSources(
inputs: CitationSourceInput[],
): ResolvedCitation[];