shadimage
nexus-ui
uicitationsource: ok · hit

Citation

Inline source references with hover preview and multi-source carousel

npx shadcn add "https://nexus-ui.dev/r/citation.json"
free

Where it goes

testimonial

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
radix-ui@hugeicons/react@hugeicons/core-free-iconstldts

Registry dependencies

carouselhover-card

Files1 · source cached 10/8/2026, 2:57:36 PM

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-lg 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 cursor-pointer items-center justify-center rounded-full text-muted-foreground 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 active:scale-97",
        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 cursor-pointer items-center justify-center rounded-full text-muted-foreground 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 active:scale-97",
        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,
};

More from nexus-ui

Browse all components in this library →