LLM index: /llms.txt

Citation

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/citation
pnpm dlx shadcn@latest add @nexus-ui/citation
yarn dlx shadcn@latest add @nexus-ui/citation
bunx shadcn@latest add @nexus-ui/citation

Install the following dependencies:

npx shadcn@latest add carousel hover-card && npm install radix-ui @hugeicons/react @hugeicons/core-free-icons tldts
pnpm dlx shadcn@latest add carousel hover-card && pnpm add radix-ui @hugeicons/react @hugeicons/core-free-icons tldts
yarn dlx shadcn@latest add carousel hover-card && yarn add radix-ui @hugeicons/react @hugeicons/core-free-icons tldts
bunx shadcn@latest add carousel hover-card && bun add radix-ui @hugeicons/react @hugeicons/core-free-icons tldts

Copy and paste the following code into your project.

components/nexus-ui/citation.tsx
"use client";

import * as React from "react";
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { parse as parseDomain } from "tldts";

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  type CarouselApi,
} from "@/components/ui/carousel";
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";

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;
};

const HTTP_PROTOCOL_RE = /^https?:$/i;
const tryParseUrl = (value: string): URL | null => {
  try {
    return new URL(value);
  } catch {
    return null;
  }
};
const parseCitationUrlOrNull = (urlStr: string): URL | null => {
  const trimmed = urlStr.trim();
  if (!trimmed) return null;
  try {
    return parseCitationUrl(trimmed);
  } catch {
    return null;
  }
};

export function parseCitationUrl(urlStr: string): URL {
  const trimmed = urlStr.trim();
  const parsed = tryParseUrl(trimmed) ?? tryParseUrl(`https://${trimmed}`);
  if (!parsed || !HTTP_PROTOCOL_RE.test(parsed.protocol)) {
    throw new Error("Citation URL must use http or https");
  }
  return parsed;
}

function formatSiteToken(token: string): string {
  const lower = token.toLowerCase();
  const isAlpha = /^[a-z]+$/i.test(lower);
  if (!isAlpha) return token;
  if (lower.length <= 3) {
    return lower.toUpperCase();
  }
  return lower.slice(0, 1).toUpperCase() + lower.slice(1);
}

function titleCaseLabel(label: string): string {
  if (!label) return label;
  return label
    .split("-")
    .filter(Boolean)
    .map(formatSiteToken)
    .join(" ");
}

export function rootDomainSiteName(url: URL): string {
  const host = url.hostname.replace(/^www\./i, "").toLowerCase();
  const { domainWithoutSuffix, isIp } = parseDomain(host, {
    allowPrivateDomains: true,
  });
  const label = isIp ? host : (domainWithoutSuffix ?? host.split(".")[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 = parseCitationUrlOrNull(input.url);
  const siteName = parsed ? rootDomainSiteName(parsed) : "Source";
  const faviconSrc = parsed
    ? `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 [, setCarouselVersion] = React.useState(0);

  const len = resolved.length;
  const clampedActiveIndex =
    len === 0 ? 0 : Math.min(Math.max(0, activeIndex), len - 1);
  const contextActiveIndex = carouselApi
    ? carouselApi.selectedScrollSnap()
    : clampedActiveIndex;
  const canScrollPrev = carouselApi?.canScrollPrev() ?? false;
  const canScrollNext = carouselApi?.canScrollNext() ?? false;
  const carouselCount = carouselApi?.scrollSnapList().length ?? 0;
  const carouselCurrent = carouselApi ? carouselApi.selectedScrollSnap() + 1 : 1;

  const onCarouselChange = React.useCallback(() => {
    if (!carouselApi) return;
    setActiveIndex(carouselApi.selectedScrollSnap());
    setCarouselVersion((v) => v + 1);
  }, [carouselApi]);

  React.useEffect(() => {
    if (!carouselApi) return;
    carouselApi.on("select", onCarouselChange);
    carouselApi.on("reInit", onCarouselChange);
    return () => {
      carouselApi.off("select", onCarouselChange);
      carouselApi.off("reInit", onCarouselChange);
    };
  }, [carouselApi, onCarouselChange]);

  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: contextActiveIndex,
      setActiveIndex,
      carouselApi,
      setCarouselApi: setCarouselApiCb,
      scrollPrev,
      scrollNext,
      canScrollPrev,
      canScrollNext,
      carouselCurrent,
      carouselCount,
    }),
    [
      resolved,
      contextActiveIndex,
      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"
> & {
  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]!;
  const hasValidUrl = !!c.url;

  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-5.5 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-1.5 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 || !hasValidUrl ? (
        <span className={cn(baseClassName, className)}>{chipBody}</span>
      ) : (
        <a
          href={c.url}
          target="_blank"
          rel="noreferrer"
          className={cn(baseClassName, className, "not-prose")}
        >
          {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}
    />
  );
}

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());
  }, [wrapRef, 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>
  );
}

export type CitationSourcesBadgeProps = Omit<
  React.ComponentPropsWithoutRef<"div">,
  "children"
> & {
  showFavicons?: boolean;
  label?: React.ReactNode;
};

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 items-center gap-1.5 rounded-full bg-secondary pr-2 w-fit",
        showFavicons ? "pl-1" : "pl-2",
        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.slice(0, 3).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"> & {
  showTitle?: boolean;
  showDescription?: boolean;
  showSource?: boolean;
};

function CitationItem({
  className,
  children,
  href,
  showTitle = true,
  showDescription = true,
  showSource = true,
  ...props
}: CitationItemProps) {
  const c = useResolvedCitation("CitationItem");
  const resolvedHref = href ?? c.url;
  const safeHref = resolvedHref ? parseCitationUrlOrNull(resolvedHref)?.href : "";
  const hasValidUrl = !!safeHref;
  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={hasValidUrl ? safeHref : undefined}
      target={hasValidUrl ? "_blank" : undefined}
      rel={hasValidUrl ? "noreferrer" : undefined}
      {...props}
    >
      {children ?? defaultContent}
    </a>
  );
}

type CitationSourceProps = React.HTMLAttributes<HTMLDivElement>;

function CitationSource({
  className,
  ...props
}: CitationSourceProps) {
  return (
    <div
      data-slot="citation-source"
      className={cn("mt-2 flex items-center gap-1.5", className)}
      {...props}
    >
      <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,
  CitationItem,
  CitationSourcesBadge,
  CitationSiteName,
  CitationSource,
  CitationTrigger,
};

Update 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.

Wikipedia+2Mozilla+1Github+2Ycombinator+2
"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>
          <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

Use Citation with useChat when your model returns sources as UIMessage parts (for example type: "source-url"). The AI SDK does not standardize citation markup across providers, and Streamdown does not resolve footnotes for you—so you collect URLs from message parts, match them to inline markers like [1] in the assistant text, and swap those markers for Citation inside markdown by overriding the anchor renderer.

See Prompt Input for the same POST /api/chat shape; below extends it with sendSources: true and a client-side inline-citation pipeline.

Citation UI depends on providers and models that emit source parts. If your model returns text only, there are no citations to render.

Install the AI SDK and a provider that emits sources

Add ai, @ai-sdk/react, and a provider package whose models surface search or citation URLs in the UI message stream (for example @ai-sdk/perplexity for Sonar). Many chat models stream text only—without source-url (or equivalent) parts, you have nothing to map [1] to.

npm install ai @ai-sdk/react @ai-sdk/perplexity

Create a chat API route that streams sources

Call streamText, then return toUIMessageStreamResponse({ sendSources: true }) so the client receives source metadata alongside text. Without sendSources, assistant messages may omit source-url parts even when the underlying model found URLs. Pick a model your provider documents as search- or citation-capable.

app/api/chat/route.ts
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { perplexity } from "@ai-sdk/perplexity";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: perplexity("sonar"),
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse({
    sendSources: true,
  });
}

Add helpers that turn markers into citation UI

Assistant prose often contains bracket references ([1], [1][2][4]). Treat the ordered list of source-url parts as the bibliography: [1] is the first URL, [2] the second, and so on. withInlineCitationLinks rewrites each run of adjacent markers into a single markdown link whose href uses a dedicated https://… prefix (custom schemes are often stripped by sanitizers). createInlineCitationComponents passes a custom a component to Streamdown: real links stay as <a>; marker links parse the id list, resolve rows from your sources array, and render Citation, CitationTrigger, and CitationContent.

lib/inline-citations.tsx
"use client";

import * as React from "react";
import {
  Citation,
  CitationCarousel,
  CitationCarouselContent,
  CitationCarouselHeader,
  CitationCarouselIndex,
  CitationCarouselItem,
  CitationCarouselNext,
  CitationCarouselPagination,
  CitationCarouselPrev,
  CitationContent,
  CitationItem,
  CitationSourcesBadge,
  CitationTrigger,
  type CitationSourceInput,
} from "@/components/nexus-ui/citation";

const GROUP_RE = /((?:\[\d+\])+)/g;
const ID_RE = /\[(\d+)\]/g;
const PREFIX = "https://citations.local/";

export function withInlineCitationLinks(text: string) {
  return text.replace(GROUP_RE, (match) => {
    const ids = [...match.matchAll(ID_RE)]
      .map(([, id]) => Number(id))
      .filter((id) => Number.isInteger(id) && id > 0);
    if (ids.length === 0) return match;
    const label = ids.map((id) => `[${id}]`).join("");
    return `[${label}](${PREFIX}${ids.join(",")})`;
  });
}

function parseIdsFromHref(href: string) {
  if (!href.startsWith(PREFIX)) return [];
  return href
    .slice(PREFIX.length)
    .split(",")
    .map((id) => Number(id))
    .filter((id) => Number.isInteger(id) && id > 0);
}

function citationsFromIds(ids: number[], sources: CitationSourceInput[]) {
  return Array.from(new Set(ids))
    .map((id) => sources[id - 1])
    .filter(Boolean) as CitationSourceInput[];
}

export function createInlineCitationComponents(sources: CitationSourceInput[]) {
  return {
    a: ({ href, children, ...props }: any) => {
      if (typeof href !== "string") {
        return React.createElement("a", { ...props, href }, children);
      }

      const ids = parseIdsFromHref(href);
      if (ids.length === 0) {
        return React.createElement("a", { ...props, href }, children);
      }

      const citations = citationsFromIds(ids, sources);
      if (citations.length === 0) return <>{children}</>;

      return (
        <>
          {" "}
          <Citation citations={citations}>
            <CitationTrigger />
            <CitationContent>
              {citations.length > 1 ? (
                <CitationCarousel>
                  <CitationCarouselHeader>
                    <CitationSourcesBadge />

                    <CitationCarouselPagination>
                      <CitationCarouselPrev />
                      <CitationCarouselIndex />
                      <CitationCarouselNext />
                    </CitationCarouselPagination>
                  </CitationCarouselHeader>

                  <CitationCarouselContent>
                    {citations.map((citation, index) => (
                      <CitationCarouselItem key={citation.url} index={index}>
                        <CitationItem />
                      </CitationCarouselItem>
                    ))}
                  </CitationCarouselContent>
                </CitationCarousel>
              ) : (
                <CitationItem />
              )}
            </CitationContent>
          </Citation>
        </>
      );
    },
  };
}

Render assistant markdown with useChat and MessageMarkdown

For each assistant message, join text parts (for example with isTextUIPart), collect source-url parts into { url, title } objects for Citation, then pass createInlineCitationComponents(sources) into MessageMarkdown via components. Feed withInlineCitationLinks(text) as children so markers become links before Streamdown renders. The example below shows one assistant turn; in a full chat, map messages the same way as in Message.

app/citations-chat/page.tsx
"use client";

import * as React from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
import { MessageMarkdown } from "@/components/nexus-ui/message";
import {
  createInlineCitationComponents,
  withInlineCitationLinks,
} from "@/lib/inline-citations";

function textFromMessage(message: UIMessage) {
  return message.parts.filter(isTextUIPart).map((p) => p.text).join("");
}

function sourceUrlPartsFromMessage(message: UIMessage) {
  return message.parts.filter(
    (p): p is Extract<UIMessage["parts"][number], { type: "source-url" }> =>
      p.type === "source-url",
  );
}

export default function CitationsChatPage() {
  const { messages } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });

  const assistant = [...messages].reverse().find((m) => m.role === "assistant");
  if (!assistant) return null;

  const text = textFromMessage(assistant);
  const sources = sourceUrlPartsFromMessage(assistant).map((s) => ({
    url: s.url,
    title: s.title?.trim() || s.url,
  }));

  return (
    <MessageMarkdown components={createInlineCitationComponents(sources)}>
      {withInlineCitationLinks(text)}
    </MessageMarkdown>
  );
}

Provider limits and enriching previews

Only some providers and models return sources through the AI SDK (for example Perplexity Sonar, some Grok variants). Others stream plain text with no source-url parts. When sources exist, their shape can differ by provider.

Source parts usually give you at least a URL. Citation accepts optional title and description for the hover card; the SDK does not fetch page metadata for you. To show real titles or snippets, call a link-preview or metadata service (or your own scraper) and merge the results into the objects you pass to citations before rendering.

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[];
View as markdown Edit on GitHub