AI output
Citation
A small numbered source marker inside a sentence that opens a preview card with the site, title and snippet, where the card rises out of a light blur on hover or focus and sinks back when the pointer or focus leaves.
Live preview
Loading preview
A short paragraph about city bike lanes with three markers: two open cards with a site, title and snippet, the third is unavailable. Replay opens the first card, then the second, then the unavailable one.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add citationUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Citation, type CitationSource } from './citation';
const SOURCES: CitationSource[] = [
{
title: 'Protected bike lanes and street safety',
url: 'https://www.citystreets.example/reports/bike-lanes',
snippet:
'Streets with a physical barrier saw far fewer crashes involving people on bikes than streets with paint only.',
},
{
title: 'How many people ride to work, by city',
url: 'https://www.commutedata.example/ride-share',
snippet: 'Ride counts rose most in cities that finished a connected network, not single short lanes.',
},
{
title: 'Planning notes from the transport office',
url: 'https://www.transportoffice.example/notes/2026',
},
];
// Replay opens the first card, then the second, then the one that is not available.
const SCRIPT: [at: number, open: number | null][] = [
[400, 0],
[2400, 1],
[4400, 2],
[6400, null],
];
export default function CitationDemo({ replay = 0 }: { replay?: number }) {
const [open, setOpen] = React.useState<number | null>(null);
React.useEffect(() => {
if (replay === 0) return;
const timers = SCRIPT.map(([at, index]) => window.setTimeout(() => setOpen(index), at));
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
const marker = (index: number, status: 'available' | 'unavailable' = 'available') => (
<Citation
label={index + 1}
source={SOURCES[index]}
status={status}
open={open === index}
onOpenChange={(next) => setOpen((now) => (next ? index : now === index ? null : now))}
/>
);
return (
<p className="m-0 w-full max-w-[520px] text-md leading-7 text-secondary">
Cities that build bike lanes as a connected network see more daily riders {marker(0)}. Lanes that keep a
physical barrier between bikes and cars also record fewer serious crashes {marker(1)}. A few early plans
have not been published yet {marker(2, 'unavailable')}.
</p>
);
}
API
Parts: root (inline wrapper), marker (link to the source, shows the number), preview card (preview card primitive): site icon, site name, title, snippet, unavailable line (in the card when the source could not be loaded).
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | number | - | Marker text, usually the source number, for example 2. Required. |
| source | { title: string; url: string; snippet?: string; icon?: slot } | undefined | The source the marker links to and the card describes. Without it the citation shows as unavailable. |
| status | "available" | "unavailable" | "available" | Use unavailable when the source could not be fetched. |
| openDelay | number | 120 | Hover time in ms before the card opens; focus opens it at once. |
| closeDelay | number | 150 | Time in ms after the pointer leaves before the card closes; blur closes it at once. |
| open | boolean | undefined | Controlled state of the card; leave it out to let hover and focus decide. |
| Event | Description |
|---|---|
| openChange | The preview card opened or closed. Payload: the new open state. |
| Slot | Description |
|---|---|
| icon | Site icon (favicon) in the card; a globe icon by default. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| CIT1 | Card opens (openDelay after hover, at once on focus) | preview card | opacity, translate y (from the side it opens on), blur | 0, distance-rise, 2px → 1, 0, 0 | duration-sm (200)ease-out | Shows at once |
| CIT2 | Card closes (closeDelay after pointer leave, at once on blur or Escape) | preview card | opacity, translate y (back to the side it opened on) | 1, 0 → 0, distance-rise | duration-xs (160)ease-exit | Hides at once |
| CIT3 | Hover, or the card opens or closes | marker | background color, text color | bg-tertiary, text-tertiary → bg-quaternary, text-primary (open: bg-brand-solid, text-white) | duration-xs (160)ease-out | Swaps at once |
Accessibility
- The marker is a real link to the source (new tab, rel noreferrer) with an accessible name such as "Source 2: Rainfall records by region"; when unavailable the name ends with ", not available".
- The card is a preview card, not a tooltip: it adds a visual preview for pointer and keyboard users, holds no focusable content, and Tab moves on to the next link. Everything a screen reader needs is in the marker name, so the card itself is not announced.
- The card opens on hover after openDelay and at once on keyboard focus, and closes on pointer leave after closeDelay, on blur and on Escape.
- Unavailable markers use a strike-through plus the words in the name and in the card, never color alone.
- The marker has a visible focus ring (Focus rings/focus-ring); the card flips above the marker near the bottom of the screen and shifts left near the right edge so it is never cut off.