Skip to content
Star on GitHub
Sources and outputCitation hover card
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Citation 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.

$npx designme add citation-hover-card

Teams that show the annual price first see higher paid conversion, and the effect is stronger when the monthly price stays visible beside it. Your own data shows the same pattern since the September update.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add citation-hover-card

React

$npx designme add citation-hover-card --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything CitationHoverCard takes.

PropTypeWhat it does
textrequiredstringThe answer. [n] cites source n; a blank line starts a new paragraph
sourcesrequiredCitationHoverCardSource[]
openLabelstringThe link in the card's corner
onOpenSource(source: CitationHoverCardSource, index: number) => voidCalled when the card's link is clicked, with the source and its index
opennumber | nullOpens 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) => voidCalled when a card opens or closes, with the source's index, or null
classNamestring
05

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(); });
});
06

Questions

What people ask about it.

How do I mark which part of the source backs up the claim?
quote accepts 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 calls onOpenSource, so you can open the file in your own viewer.
07

More in Sources and output

What the agent read, what it produced and where each answer came from.

Code viewerCode the agent wrote, with line notes you can ask about.
Terminal streamShell commands the agent runs, with their output as it streams in.
Source stackEverything the agent read, folded into one quiet pill.
Field extractionThe agent pulls fields out of a document and shows where each came from.
Metric insightA number, its trend, and the agent's read on why it moved.
Details panelProperties of a run you can change in place.
Task tableAgent tasks in a sortable table with bulk actions.