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.
npx shadcn@latest add @paragon/citation-chip"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>
);
}