AI & Chat

Citation Chip

An inline numbered source citation whose superscript chip opens an origin-aware preview card with title, domain, and favicon placeholder after a 150ms hover delay.

Install

npx shadcn@latest add @paragon/citation-chip

citation-chip.tsx

"use client";

import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";

function domainOf(url?: string): string | null {
  if (!url) return null;
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    return null;
  }
}

export interface CitationChipProps
  extends Omit<React.ComponentProps<"a">, "href"> {
  /** Citation number rendered in the chip. */
  index: number;
  /** Source title shown in the preview card. */
  title: string;
  /** Source URL — the domain and favicon placeholder derive from it. */
  url?: string;
  /** Optional snippet shown under the source in the preview. */
  description?: string;
  /** ms before the preview opens on hover. */
  openDelay?: number;
}

/**
 * Inline numbered source citation. A superscript chip that opens a small
 * origin-aware preview card — title, domain, favicon placeholder — after a
 * 150ms hover delay. Built on Radix HoverCard, so it also opens on keyboard
 * focus, and the card scales in from the trigger side via
 * --radix-hover-card-content-transform-origin.
 */
export function CitationChip({
  index,
  title,
  url,
  description,
  openDelay = 150,
  className,
  ...props
}: CitationChipProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const domain = domainOf(url);

  return (
    <HoverCardPrimitive.Root openDelay={openDelay} closeDelay={100}>
      <HoverCardPrimitive.Trigger asChild>
        <a
          href={url}
          target={url ? "_blank" : undefined}
          rel={url ? "noreferrer" : undefined}
          tabIndex={url ? undefined : 0}
          aria-label={`Source ${index}: ${title}`}
          data-slot="citation-chip"
          className={cn(
            "relative -top-[0.35em] mx-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-[5px] bg-muted px-1 align-baseline text-[10px] leading-none font-medium text-muted-foreground no-underline tabular-nums transition-[background-color,color] duration-150 ease-out after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground",
            className,
          )}
          {...props}
        >
          {index}
        </a>
      </HoverCardPrimitive.Trigger>
      <HoverCardPrimitive.Portal>
        <HoverCardPrimitive.Content
          sideOffset={6}
          collisionPadding={8}
          data-citation={id}
          className="z-50 w-72 rounded-xl bg-popover p-3 text-popover-foreground shadow-overlay outline-none [transform-origin:var(--radix-hover-card-content-transform-origin)]"
        >
          <style href={`paragon-citation-chip-${id}`} precedence="paragon">{`
            @keyframes citation-in-${id} {
              from { opacity: 0; transform: scale(0.97); filter: blur(2px); }
              to { opacity: 1; transform: scale(1); filter: blur(0px); }
            }
            @keyframes citation-out-${id} {
              to { opacity: 0; transform: scale(0.99); filter: blur(2px); }
            }
            [data-citation="${id}"][data-state="open"] {
              animation: citation-in-${id} 150ms var(--ease-out);
            }
            [data-citation="${id}"][data-state="closed"] {
              animation: citation-out-${id} 100ms var(--ease-exit) forwards;
            }
            @media (prefers-reduced-motion: reduce) {
              [data-citation="${id}"] { animation: none !important; }
            }
          `}</style>
          <div className="flex items-start gap-2.5">
            <span
              aria-hidden
              className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground uppercase select-none"
            >
              {domain?.[0] ?? "?"}
            </span>
            <div className="min-w-0">
              <p className="truncate text-[13px] font-medium">{title}</p>
              {domain && (
                <p className="mt-0.5 truncate text-xs text-muted-foreground">
                  {domain}
                </p>
              )}
            </div>
          </div>
          {description && (
            <p className="mt-2 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
              {description}
            </p>
          )}
        </HoverCardPrimitive.Content>
      </HoverCardPrimitive.Portal>
    </HoverCardPrimitive.Root>
  );
}