Sources and outputCitation hover card
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callCitation hover card
Hover a citation to see the exact passage it came from. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add citation-hover-cardReact
npx designme add citation-hover-card --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Citation hover card renders an agent's answer with numbered citations in the text. Hover, focus or tap a number and a small card opens under it with the source's site, title and the exact passage the claim relies on, with the key words highlighted.
- When an agent answers from web pages or your own files and people should be able to check each claim.
- When you want the evidence one hover away instead of in a list at the bottom.
- When the important part of a long source is a single sentence you can highlight.
- When citations need to work with a mouse, a keyboard and a touch screen.
- Write
textwith markers like[1]. Each marker becomes a numbered button, and a blank line starts a new paragraph. - Each source has a coloured
mark, asite, atitle, aquoteand an optionalmetaline. Wrap the key words of the quote in<mark>to highlight them. - The card waits a moment on hover, stays open while the pointer moves into it, and closes shortly after the pointer leaves.
- On touch, a tap opens the card and a tap anywhere else closes it. Resizing the window also closes it.
- The card's link opens
hrefin a new tab and callsonOpenSourcewith the source and its index.openLabelsets the link text. openandonOpenChangelet you open a source's card from outside, for example from a source list elsewhere on the page.
- Each citation is a button with an
aria-labellike "Source 2: Price anchoring in plan selection". - Focusing a citation with the keyboard opens its card. Moving focus away or pressing Escape closes it, and Escape leaves the focus on the citation.
- Enter or Space on a citation puts the focus on its card's link, so it can be reached; Escape from the card puts the focus back on the citation.
- The card has
role="dialog"andaria-label="Source".
Usage
Import it and drive it from your own state.
Import
import { CitationHoverCard } from '@/components/designme/citation-hover-card/CitationHoverCard';Example
The demo above as a React component, 30 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component. Hover, focus or tap a citation to see its source.
import { useState } from 'react';
import { CitationHoverCard, type CitationHoverCardSource } from './CitationHoverCard';
import { useScript } from '../lib/useScript';
const SOURCES: CitationHoverCardSource[] = [
{ mark: 'B', color: '#4a5a8a', site: 'baymard.com', title: 'Pricing page UX benchmark', meta: 'Read 4 days ago',
quote: <>Of 64 SaaS sites tested, those leading with the annual plan saw <mark>a 12 to 20% higher trial to paid rate</mark> than monthly first layouts.</> },
{ mark: 'C', color: '#d9480f', site: 'cxl.com', title: 'Price anchoring in plan selection', meta: 'Read 4 days ago',
quote: <>When the monthly price stayed visible with a strike through, <mark>annual selection rose by a further 9 points</mark> in the anchored group.</> },
{ mark: 'C', color: 'var(--dm-pm)', site: 'Drive', title: 'Q3 conversion export.csv', meta: 'Your file',
quote: <>Rows 412 to 480: <mark>trial to paid moves from 18.6% to 26.3%</mark> between Sep 18 and Oct 17.</> }
];
export default function CitationHoverCardDemo() {
// shows the first card once on its own, then puts it away, unless you are already pointing at a citation
const [open, setOpen] = useState<number | null>();
useScript(async ({ wait }) => {
await wait(1400); setOpen(o => o === undefined ? 0 : o);
await wait(2800); setOpen(o => o === 0 ? null : o);
});
return (
<div style={{ width: '100%' }}>
<CitationHoverCard sources={SOURCES} open={open} onOpenChange={setOpen}
text="Teams that show the annual price first see higher paid conversion[1], and the effect is stronger when the monthly price stays visible beside it[2]. Your own data shows the same pattern since the September update[3]." />
</div>
);
}
Props
Everything CitationHoverCard takes.
| Prop | Type | What it does |
|---|---|---|
textrequired | string | The answer. [n] cites source n; a blank line starts a new paragraph |
sourcesrequired | CitationHoverCardSource[] | |
openLabel | string | The link in the card's corner |
onOpenSource | (source: CitationHoverCardSource, index: number) => void | Called when the card's link is clicked, with the source and its index |
open | number | null | Opens a source's card from outside (0 is the first source), or closes it with null. Hover, focus and taps still work |
onOpenChange | (index: number | null) => void | Called when a card opens or closes, with the source's index, or null |
className | string |
Code
The full source, as the CLI installs it.
CitationHoverCard.tsxReact · 136 lines
'use client';
import { Fragment, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { Icon } from '../lib/icons';
import '../designme.css';
import './citation-hover-card.css';
export interface CitationHoverCardSource {
/** One or two letters on the source's coloured mark, like "B" */
mark: string;
/** The mark's colour, like '#4a5a8a' or 'var(--dm-pm)' */
color: string;
/** Where it comes from, like "baymard.com" or "Drive" */
site: string;
title: string;
/** The passage the answer relies on. Wrap the part that matters in <mark> */
quote: ReactNode;
/** The quiet line in the corner, like "Read 4 days ago" */
meta?: string;
/** Opens in a new tab from the card's link */
href?: string;
}
export interface CitationHoverCardProps {
/** The answer. [n] cites source n; a blank line starts a new paragraph */
text: string;
sources: CitationHoverCardSource[];
/** The link in the card's corner */
openLabel?: string;
/** Called when the card's link is clicked, with the source and its index */
onOpenSource?: (source: CitationHoverCardSource, index: number) => void;
/** Opens a source's card from outside (0 is the first source), or closes it with null. Hover, focus and taps still work */
open?: number | null;
/** Called when a card opens or closes, with the source's index, or null */
onOpenChange?: (index: number | null) => void;
className?: string;
}
interface Shown { i: number; x: number; y: number; n: number }
/** An answer with numbered citations. Hovering, focusing or tapping one shows its source in a small card underneath. */
export function CitationHoverCard({ text, sources, openLabel = 'Open source', onOpenSource, open: openAt, onOpenChange, className }: CitationHoverCardProps) {
const box = useRef<HTMLDivElement>(null);
const card = useRef<HTMLDivElement>(null);
// `at` is the open card; `last` keeps its content while it fades out
const [at, setAt] = useState<Shown | null>(null);
const [last, setLast] = useState(0);
const cur = useRef<number | null>(null);
const showT = useRef<ReturnType<typeof setTimeout>>(undefined), hideT = useRef<ReturnType<typeof setTimeout>>(undefined);
// the first citation of each source, which a card opened from outside sits under
const marks = useRef(new Map<number, HTMLButtonElement>());
const told = useRef(onOpenChange);
useEffect(() => { told.current = onOpenChange; });
// the citation the card belongs to, whether the card should take the focus once it shows (Enter or Space), and whether a
// focus coming back to the citation (from Escape) should leave the card shut
const curEl = useRef<HTMLElement | null>(null), focusLink = useRef(false), back = useRef(false);
const show = (i: number, el: HTMLElement) => {
clearTimeout(hideT.current); if (cur.current === i) return;
const b = box.current, c = card.current; if (!b || !c) return;
const br = b.getBoundingClientRect(), cr = el.getBoundingClientRect();
const x = Math.max(0, Math.min(cr.left - br.left - 24, br.width - c.offsetWidth));
cur.current = i; curEl.current = el; setLast(i); told.current?.(i);
// a new key restarts the entrance, so the card always rises in place instead of sliding across
setAt(a => ({ i, x, y: cr.bottom - br.top + 8, n: (a?.n ?? 0) + 1 }));
};
const hide = (now = false) => {
clearTimeout(hideT.current); clearTimeout(showT.current);
const go = () => { if (cur.current !== null) told.current?.(null); cur.current = null; setAt(a => a && { ...a, i: -1 }); };
if (now) go(); else hideT.current = setTimeout(go, 140);
};
useEffect(() => {
const down = (e: PointerEvent) => { const t = e.target as Element; if (!card.current?.contains(t) && !t.closest?.('.dm-chx-c')) hide(true); };
const resize = () => hide(true);
// Escape puts the card away while the focus or the pointer is on the answer, and leaves the focus where it was, or on
// its citation when it was in the card
const key = (e: KeyboardEvent) => { const b = box.current; if (e.key === 'Escape' && cur.current !== null && b && (b.contains(document.activeElement) || b.matches(':hover'))) {
e.preventDefault(); const to = card.current?.contains(document.activeElement) ? curEl.current : null; hide(true); if (to) { back.current = true; to.focus({ preventScroll: true }); } } };
document.addEventListener('pointerdown', down); document.addEventListener('keydown', key); addEventListener('resize', resize);
return () => { document.removeEventListener('pointerdown', down); document.removeEventListener('keydown', key); removeEventListener('resize', resize); clearTimeout(hideT.current); clearTimeout(showT.current); };
}, []);
// opened with Enter or Space, the card takes the focus on its link once it shows
useEffect(() => { if (focusLink.current && at && at.i >= 0) { focusLink.current = false; card.current?.querySelector<HTMLElement>('a')?.focus({ preventScroll: true }); } }, [at]);
// a card opened or closed from outside
useEffect(() => {
if (openAt === undefined) return;
if (openAt === null) { if (cur.current !== null) hide(true); return; }
const el = marks.current.get(openAt); if (el && cur.current !== openAt) show(openAt, el);
}, [openAt]); // eslint-disable-line react-hooks/exhaustive-deps
const cite = (n: number, key: string) => {
const i = n - 1;
if (!sources[i]) return <Fragment key={key}>[{n}]</Fragment>;
return (
<button type="button" key={key} ref={el => { if (el && !marks.current.has(i)) marks.current.set(i, el); }} className={cx('dm-chx-c', at?.i === i && 'dm-on')} aria-label={`Source ${n}: ${sources[i].title}`}
onPointerEnter={e => { if (e.pointerType === 'touch') return; const el = e.currentTarget; clearTimeout(showT.current); clearTimeout(hideT.current); showT.current = setTimeout(() => show(i, el), cur.current !== null ? 0 : 90); }}
onPointerLeave={e => { if (e.pointerType === 'touch') return; clearTimeout(showT.current); hide(); }}
onFocus={e => { if (back.current) { back.current = false; return; } show(i, e.currentTarget); }} onBlur={e => { if (!card.current?.contains(e.relatedTarget as Node)) hide(); }}
// touch has no hover: a tap shows the card, the next tap anywhere else hides it. Enter or Space (a click with no
// pointer) also puts the focus on the card's link, so the keyboard can reach it
onClick={e => { const key = e.detail === 0; if (cur.current !== i) { focusLink.current = key; show(i, e.currentTarget); } else if (key) card.current?.querySelector<HTMLElement>('a')?.focus({ preventScroll: true }); }}>{n}</button>
);
};
const paragraphs = text.split(/\n\s*\n/).map((p, k) => (
<p key={k} className="dm-msg dm-chx-p">{p.split(/\[(\d+)\]/).map((part, j) => j % 2 ? cite(+part, k + '-' + j) : part)}</p>
));
const s = sources[at && at.i >= 0 ? at.i : last];
const open = !!at && at.i >= 0;
const follow = (e: MouseEvent) => { if (!s?.href) e.preventDefault(); onOpenSource?.(s, sources.indexOf(s)); };
return (
<div data-dm="" ref={box} className={cx('dm-chx', className)}>
{paragraphs}
<div key={at?.n ?? 0} ref={card} className={cx('dm-chx-card dm-panel', open && 'dm-show')} role="dialog" aria-label="Source"
style={at ? { left: at.x, top: at.y } : undefined}
onPointerEnter={() => clearTimeout(hideT.current)} onPointerLeave={e => { if (e.pointerType !== 'touch') hide(); }}
onBlur={e => { const t = e.relatedTarget as Node | null; if (cur.current !== null && !card.current?.contains(t) && t !== curEl.current) hide(); }}>
{s && (
<div className="dm-chx-in">
<div className="dm-chx-sh"><span className="dm-fv" style={{ background: s.color }}>{s.mark}</span><span>{s.site}</span></div>
<div className="dm-chx-t">{s.title}</div>
<div className="dm-chx-q">{s.quote}</div>
<div className="dm-chx-f">
<span>{s.meta}</span>
<a href={s.href ?? '#'} target={s.href ? '_blank' : undefined} rel="noreferrer" onClick={follow}>{openLabel} <Icon name="ext" size="xs" /></a>
</div>
</div>
)}
</div>
</div>
);
}
citation-hover-card.cssReact · 20 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Citation hover card ---------- */
.dm-chx{position:relative;overflow:visible;padding-bottom:150px}
.dm-chx-p{line-height:1.8}
.dm-chx-c{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 5px;margin:0 1px 0 3px;border-radius:6px;background:var(--dm-hover);color:var(--dm-fg2);font-size:11px;font-variant-numeric:tabular-nums;vertical-align:2px;transition:background .2s,color .2s}
.dm-chx-c.dm-on{background:var(--dm-accent);color:var(--dm-on-accent)}
@media (hover:hover) and (pointer:fine){.dm-chx-c:hover{background:var(--dm-accent);color:var(--dm-on-accent)}}
/* hidden as well as clear when put away, so Tab never lands on its link */
.dm-chx-card{position:absolute;left:0;top:0;width:min(290px,calc(100% - 8px));padding:var(--dm-s-3);opacity:0;visibility:hidden;pointer-events:none;z-index:4;box-shadow:var(--dm-shadow-pop);transform-origin:50% 0;transition:opacity .16s ease-out,visibility 0s .16s}
.dm-chx-card.dm-show{opacity:1;visibility:visible;pointer-events:auto;animation:dm-chxin .5s var(--dm-ease) both;transition-property:opacity}
@keyframes dm-chxin{from{opacity:0;transform:translateY(10px) scale(.985);filter:blur(3px)}60%{filter:blur(0)}to{opacity:1;transform:none;filter:none}}
.dm-chx-sh{display:flex;align-items:center;gap:var(--dm-s-2);font-size:12px;color:var(--dm-muted)}
.dm-chx-sh .dm-fv{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:9px;font-weight:500;color:#fff;flex:none}
.dm-chx-t{font-size:13px;font-weight:500;margin:var(--dm-s-2) 0 6px}
.dm-chx-q{font-size:12.5px;line-height:1.55;color:var(--dm-fg2);padding-left:10px;box-shadow:inset 2px 0 0 var(--dm-accent-mark,var(--dm-accent))}
.dm-chx-q mark{background:var(--dm-sel);color:var(--dm-fg);border-radius:3px;padding:0 2px}
.dm-chx-f{display:flex;align-items:center;justify-content:space-between;margin-top:10px;font-size:12px;color:var(--dm-muted)}
.dm-chx-f a{color:var(--dm-accent-fg,var(--dm-accent));display:inline-flex;align-items:center;gap:var(--dm-s-1);text-decoration:none}
@media (hover:hover) and (pointer:fine){.dm-chx-f a:hover{text-decoration:underline;text-underline-offset:2px}}
citation-hover-card.htmlHTML · 9 lines
<div class="card bare" data-c="citation-hover">
<div class="stage-in">
<div class="chx">
<p class="msg chx-p">Teams that show the annual price first see higher paid conversion<button class="chx-c" data-i="0">1</button>, and the effect is stronger when the monthly price stays visible beside it<button class="chx-c" data-i="1">2</button>. Your own data shows the same pattern since the September update<button class="chx-c" data-i="2">3</button>.</p>
<div class="chx-card panel keep" role="dialog" aria-label="Source"><div class="chx-in"></div></div>
</div>
</div>
</div>
citation-hover-card.cssHTML · 19 lines
/* ---------- Citation hover card ---------- */
.chx{position:relative;overflow:visible;padding-bottom:150px}
.chx-p{line-height:1.8}
.chx-c{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 5px;margin:0 1px 0 3px;border-radius:6px;background:var(--hover);color:var(--fg2);font-size:11px;font-variant-numeric:tabular-nums;vertical-align:2px;transition:background .2s,color .2s}
.chx-c.on{background:var(--accent);color:var(--on-accent)}
@media (hover:hover) and (pointer:fine){.chx-c:hover{background:var(--accent);color:var(--on-accent)}}
/* hidden as well as clear when put away, so Tab never lands on its link */
.chx-card{position:absolute;left:0;top:0;width:min(290px,calc(100% - 8px));padding:var(--s-3);opacity:0;visibility:hidden;pointer-events:none;z-index:4;box-shadow:var(--shadow-pop);transform-origin:50% 0;transition:opacity .16s ease-out,visibility 0s .16s}
.chx-card.show{opacity:1;visibility:visible;pointer-events:auto;animation:chxin .5s var(--ease) both;transition-property:opacity}
@keyframes chxin{from{opacity:0;transform:translateY(10px) scale(.985);filter:blur(3px)}60%{filter:blur(0)}to{opacity:1;transform:none;filter:none}}
.chx-sh{display:flex;align-items:center;gap:var(--s-2);font-size:12px;color:var(--muted)}
.chx-sh .fv{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:9px;font-weight:500;color:#fff;flex:none}
.chx-t{font-size:13px;font-weight:500;margin:var(--s-2) 0 6px}
.chx-q{font-size:12.5px;line-height:1.55;color:var(--fg2);padding-left:10px;box-shadow:inset 2px 0 0 var(--accent-mark,var(--accent))}
.chx-q mark{background:var(--sel);color:var(--fg);border-radius:3px;padding:0 2px}
.chx-f{display:flex;align-items:center;justify-content:space-between;margin-top:10px;font-size:12px;color:var(--muted)}
.chx-f a{color:var(--accent-fg,var(--accent));display:inline-flex;align-items:center;gap:var(--s-1);text-decoration:none}
@media (hover:hover) and (pointer:fine){.chx-f a:hover{text-decoration:underline;text-underline-offset:2px}}
citation-hover-card.jsHTML · 50 lines
DM.def('citation-hover', (r, c) => {
const S = [
['B','#4a5a8a','baymard.com','Pricing page UX benchmark','Of 64 SaaS sites tested, those leading with the annual plan saw <mark>a 12 to 20% higher trial to paid rate</mark> than monthly first layouts.','Read 4 days ago'],
['C','#d9480f','cxl.com','Price anchoring in plan selection','When the monthly price stayed visible with a strike through, <mark>annual selection rose by a further 9 points</mark> in the anchored group.','Read 4 days ago'],
['CSV','var(--pm)','Drive','Q3 conversion export.csv','Rows 412 to 480: <mark>trial to paid moves from 18.6% to 26.3%</mark> between Sep 18 and Oct 17.','Your file']
];
const card = $('.chx-card', r), inn = $('.chx-in', r), box = $('.chx', r), cites = $$('.chx-c', r);
let cur = null, hideT, showT, back = false;
/* the card exists only while a citation (or the card itself) is under the pointer */
function show(b){
clearTimeout(hideT); if (cur === b) return;
const s = S[+b.dataset.i]; cur = b;
cites.forEach(x => x.classList.toggle('on', x === b));
inn.innerHTML = `<div class="chx-sh"><span class="fv" style="background:${s[1]}">${s[0][0]}</span><span>${s[2]}</span></div><div class="chx-t">${s[3]}</div><div class="chx-q">${s[4]}</div><div class="chx-f"><span>${s[5]}</span><a href="#" onclick="return false">Open source ${I('ext','xs')}</a></div>`;
/* restart the entry each time, so it always rises from below in place and never slides sideways */
card.classList.remove('show'); void card.offsetWidth;
const br = box.getBoundingClientRect(), cr = b.getBoundingClientRect();
const x = Math.max(0, Math.min(cr.left - br.left - 24, br.width - card.offsetWidth));
card.style.left = x + 'px'; card.style.top = (cr.bottom - br.top + 8) + 'px';
card.classList.add('show');
}
function hide(now){
clearTimeout(hideT); clearTimeout(showT);
const go = () => { card.classList.remove('show'); cur = null; cites.forEach(x => x.classList.remove('on')); };
if (now) go(); else hideT = setTimeout(go, 140);
}
/* pointing at a citation shows its card, leaving it (and the card) hides it; focus shows it too, so the keyboard gets the
same card. Touch has no hover: a tap shows the card, the next tap anywhere else hides it */
const hover = e => e.pointerType !== 'touch';
cites.forEach(b => {
c.on(b, 'pointerenter', e => { if (!hover(e)) return; clearTimeout(showT); clearTimeout(hideT); showT = setTimeout(() => show(b), cur ? 0 : 90); });
c.on(b, 'pointerleave', e => { if (!hover(e)) return; clearTimeout(showT); hide(); });
c.on(b, 'focus', () => { if (back){ back = false; return; } show(b); }); c.on(b, 'blur', e => { if (!card.contains(e.relatedTarget)) hide(); });
// Enter or Space opens the card with the focus on its link, so the keyboard can reach it; Escape brings the focus back
c.on(b, 'click', e => { if (cur !== b) show(b); if (e.isTrusted && !e.detail){ const a = $('a', card); if (a) a.focus({ preventScroll:true }); } });
});
c.on(card, 'pointerenter', () => clearTimeout(hideT));
c.on(card, 'pointerleave', e => { if (hover(e)) hide(); });
c.on(card, 'focusout', e => { if (cur && !card.contains(e.relatedTarget) && e.relatedTarget !== cur) hide(); });
/* Escape puts the card away while the focus or the pointer is on the answer, and leaves the focus where it was, or on its
citation when it was in the card */
c.on(document, 'keydown', e => { if (e.key === 'Escape' && cur && (box.contains(document.activeElement) || box.matches(':hover'))){ e.preventDefault(); const to = card.contains(document.activeElement) && cur; hide(true); if (to){ back = true; to.focus({ preventScroll:true }); } } });
c.on(document, 'pointerdown', e => { if (!card.contains(e.target) && !e.target.closest('.chx-c')) hide(true); });
c.on(window, 'resize', e => { if (e.isTrusted) hide(true); });
c.offs.push(() => { clearTimeout(hideT); clearTimeout(showT); });
// the preview shows one card on its own after the entrance, then puts it away, unless you are already pointing at a citation
let mine = false; c.on(r, 'pointerover', e => { if (e.isTrusted) mine = true; }, true);
c.run(async () => { await c.wait(1400); if (mine || cur) return; show(cites[0]); await c.wait(2800); if (!mine && cur === cites[0]) hide(); });
});
Questions
What people ask about it.
- How do I mark which part of the source backs up the claim?
quoteaccepts React nodes, so wrap the passage that matters in a<mark>element. The card styles it as a highlight.- What happens if the text cites a number with no source?
- A marker like
[4]with no fourth source is left as plain text, so it never opens an empty card. - What if a source has no URL, like a private file?
- Leave out
href. The link then stays on the page and only callsonOpenSource, so you can open the file in your own viewer.
More in Sources and output
What the agent read, what it produced and where each answer came from.