Astro ships zero JavaScript by default and hydrates only the components you mark as islands 1. Pick a client directive to control when hydration happens; client:visible is the right default for anything below the fold 2.
- 1ddocs.astro.build2025Islands architectureAstro pioneered and popularized a frontend architecture called Islands. Islands architecture results in better performance by avoiding monolithic JavaScript patterns.
- 2ddocs.astro.buildTemplate directives referenceclient:load hydrates the component JavaScript immediately on page load; client:visible waits until the component enters the viewport.
import * as React from "react";
import { CitedText, SourceCard, SourceList, type Source } from "@/components/pandacoderz-ui/citation";
const sources: Source[] = [
{
id: "s1",
title: "Islands architecture",
url: "https://docs.astro.build/en/concepts/islands/",
snippet: "Astro pioneered and popularized a frontend architecture called Islands. Islands architecture results in better performance by avoiding monolithic JavaScript patterns.",
date: "2025",
},
{
id: "s2",
title: "Template directives reference",
url: "https://docs.astro.build/en/reference/directives-reference/",
snippet: "client:load hydrates the component JavaScript immediately on page load; client:visible waits until the component enters the viewport.",
},
];
export default function CitationDemo() {
const [active, setActive] = React.useState<string | null>(null);
return (
<div className="flex w-full max-w-xl flex-col gap-4">
<CitedText
sources={sources}
activeId={active}
onHover={setActive}
text="Astro ships zero JavaScript by default and hydrates only the components you mark as islands [1]. Pick a client directive to control when hydration happens; client:visible is the right default for anything below the fold [2]."
/>
<SourceList>
{sources.map((s, i) => (
<SourceCard
key={s.id}
index={i + 1}
source={s}
active={active === s.id}
onMouseEnter={() => setActive(s.id)}
onMouseLeave={() => setActive(null)}
/>
))}
</SourceList>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/citation.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/citation.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/citation.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/citation.jsonInstall the dependencies:
npm install radix-ui @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add tooltipCopy the source into your project:
"use client";
import * as React from "react";
import { ArrowTopRightOnSquareIcon } from "@heroicons-animated/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type Source = {
id: string;
title: string;
url: string;
snippet?: string;
/** Publisher or site name; derived from the URL when omitted. */
site?: string;
date?: string;
};
export function sourceSite(source: Pick<Source, "url" | "site">) {
if (source.site) return source.site;
try {
return new URL(source.url).hostname.replace(/^www\./, "");
} catch {
return source.url;
}
}
type CitationProps = Omit<React.ComponentProps<"a">, "href"> & {
/** 1-based display number. */
index: number;
source?: Source;
/** Highlights the chip; useful when the matching source card is hovered. */
active?: boolean;
};
/** Inline numbered chip that links to a source and previews it on hover. */
function Citation({ index, source, active, className, ...props }: CitationProps) {
const chip = (
<a
data-slot="citation"
data-active={active || undefined}
href={source?.url ?? "#"}
target={source ? "_blank" : undefined}
rel={source ? "noreferrer" : undefined}
className={cn(
"mx-0.5 inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-md border bg-muted px-1 align-text-top font-mono text-[10px] font-medium leading-none text-muted-foreground no-underline transition-colors hover:border-brand/50 hover:bg-brand-soft hover:text-brand data-[active]:border-brand/50 data-[active]:bg-brand-soft data-[active]:text-brand",
className,
)}
{...props}
>
{index}
</a>
);
if (!source) return chip;
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" className="max-w-72 bg-popover p-3 text-popover-foreground shadow-modal">
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">{sourceSite(source)}</span>
<span className="text-xs font-medium leading-4">{source.title}</span>
{source.snippet ? (
<span className="line-clamp-3 text-[11px] leading-4 text-muted-foreground">{source.snippet}</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
type SourceListProps = React.ComponentProps<"ol">;
function SourceList({ className, ...props }: SourceListProps) {
return (
<ol
data-slot="source-list"
className={cn("grid gap-2 sm:grid-cols-2", className)}
{...props}
/>
);
}
type SourceCardProps = Omit<React.ComponentProps<"a">, "href"> & {
index: number;
source: Source;
active?: boolean;
compact?: boolean;
};
function SourceCard({ index, source, active, compact, className, ...props }: SourceCardProps) {
const site = sourceSite(source);
return (
<li className="list-none">
<a
data-slot="source-card"
data-active={active || undefined}
href={source.url}
target="_blank"
rel="noreferrer"
className={cn(
"group/source flex h-full flex-col gap-1.5 rounded-xl border bg-card p-3 text-left no-underline transition-colors hover:border-brand/50 hover:bg-accent/40 data-[active]:border-brand/50 data-[active]:bg-brand-soft/40",
className,
)}
{...props}
>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="flex size-4 items-center justify-center rounded-full bg-muted font-mono text-[10px] font-medium text-foreground">
{index}
</span>
<span className="flex size-4 items-center justify-center rounded-sm bg-brand-soft text-[9px] font-semibold uppercase text-brand">
{site.charAt(0)}
</span>
<span className="truncate">{site}</span>
{source.date ? <span className="ml-auto shrink-0">{source.date}</span> : null}
<ArrowTopRightOnSquareIcon size={12} className="flex shrink-0 opacity-0 transition-opacity group-hover/source:opacity-100" />
</div>
<span className="line-clamp-2 text-xs font-medium leading-4 text-foreground">{source.title}</span>
{!compact && source.snippet ? (
<span className="line-clamp-2 text-[11px] leading-4 text-muted-foreground">{source.snippet}</span>
) : null}
</a>
</li>
);
}
/**
* Render markdown-ish text that contains `[n]` markers, replacing each with a
* <Citation>. Use for plain paragraphs; for full markdown keep the markers
* and post-process the rendered output instead.
*/
function CitedText({
text,
sources,
activeId,
onHover,
className,
...props
}: React.ComponentProps<"p"> & {
text: string;
sources: Source[];
activeId?: string | null;
onHover?: (id: string | null) => void;
}) {
const parts = text.split(/(\[\d+\])/g);
return (
<p data-slot="cited-text" className={cn("text-sm leading-6.5", className)} {...props}>
{parts.map((part, i) => {
const m = /^\[(\d+)\]$/.exec(part);
if (!m) return <React.Fragment key={i}>{part}</React.Fragment>;
const index = Number(m[1]);
const source = sources[index - 1];
return (
<Citation
key={i}
index={index}
source={source}
active={!!source && activeId === source.id}
onMouseEnter={() => onHover?.(source?.id ?? null)}
onMouseLeave={() => onHover?.(null)}
/>
);
})}
</p>
);
}
export { Citation, SourceList, SourceCard, CitedText };Usage
import { Citation, CitedText, SourceCard, SourceList, type Source } from "@/components/pandacoderz-ui/citation";CitedText is the fast path: give it a paragraph containing [1], [2] markers and the matching sources array.
<CitedText text="Astro hydrates only islands [1]." sources={sources} activeId={active} onHover={setActive} />
<SourceList>
{sources.map((s, i) => <SourceCard key={s.id} index={i + 1} source={s} active={active === s.id} />)}
</SourceList>For full markdown, keep the [n] markers in the model output and render <Citation> yourself from a custom paragraph or text renderer.
API Reference
Source
| Field | Type | Description |
|---|---|---|
id |
string |
Stable key used for hover linking. |
title / url |
string |
Required. |
snippet |
string |
Shown in the tooltip and card. |
site |
string |
Publisher name. Derived from the URL hostname when omitted. |
date |
string |
Optional trailing date on the card. |
Citation
| Prop | Type | Description |
|---|---|---|
index |
number |
Display number. |
source |
Source |
Enables the link and hover preview. |
active |
boolean |
Highlighted state. |