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

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

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 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 zod

Add the citation API route

Define the Zod schema in this file and export it so the client can pass the same shape to useObject.

app/api/citation/route.ts
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

app/citations-object/page.tsx
"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/openai

Add the chat API route

app/api/chat/route.ts
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

app/citations-chat/page.tsx
"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[];