Citation Hover Cards
Numbered citation badges pop into an AI answer one after another with a little bounce. Hover over one (or tap it on a phone) and a source card slides out beneath it, with the site, the page title and a short snippet. Badges pointing at the same page share a number.
How to use it
- Paste the snippet just before
</head>. - Add
data-citation-hover-cardsto the box holding the answer. - Where each source is cited, add an empty
<a href="…" data-cite></a>withdata-titleanddata-snippetfor the card. It becomes the numbered badge.
Options
Add any of these attributes to the same element.
data-cite | On each citation. A link’s address gives the site name; on a <span>, use data-href or data-site. |
data-title | On each citation: the page title for its card. |
data-snippet | On each citation: a line or two quoted from the source. |
data-autoplay | Open each card in turn by itself, until someone hovers or taps a badge. |
data-interval | How long each autoplay card stays open. Default 2200 ms. |
data-loop | Pop the badges in again each time the autoplay goes round. |
data-color | Badge colour. Default #7c3aed. |
On touch screens the first tap on a badge opens its card and a second tap follows the link. Cards keep within the box’s width, opening upwards when there’s no room below.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Citation Hover Cards: numbered citation badges pop into an AI answer one
// after another. Hover (or tap) a badge and a little source card slides out
// with the site, the page title and a snippet.
// Usage: <p data-citation-hover-cards>Text<a href="https://…" data-cite data-title="Page title" data-snippet="…"></a> more text…</p>
// Each citation is any element with data-cite: a link (its href gives the
// site), or a span with data-href or data-site. data-title and data-snippet
// fill the card. Badges are numbered in order; repeat a link to reuse its number.
// Optional (on the container): data-autoplay (open each card in turn by
// itself until someone hovers or taps), data-interval (ms each autoplay card
// stays open, default 2200), data-color (badge colour, default #7c3aed),
// data-loop (pop the badges in again each time round the autoplay).
var DEFAULTS = { interval: 2200, color: '#7c3aed' };
var css =
'[data-citation-hover-cards]{position:relative}' +
'.chc-badge{display:inline-flex;align-items:center;justify-content:center;min-width:1.45em;height:1.45em;padding:0 .35em;' +
'margin:0 .12em;border-radius:.5em;vertical-align:.12em;font-size:.68em;font-weight:700;line-height:1;font-style:normal;' +
'text-decoration:none;cursor:pointer;color:var(--chc-c);background:color-mix(in srgb,var(--chc-c) 13%,transparent);' +
'transition:background-color .2s,color .2s;-webkit-tap-highlight-color:transparent}' +
'.chc-badge.chc-on,.chc-badge:focus-visible{background:var(--chc-c);color:#fff;outline:0}' +
'.chc-card{position:absolute;z-index:5;left:0;top:0;width:min(250px,calc(100% - 8px));box-sizing:border-box;padding:.75em .85em;' +
'border-radius:12px;background:var(--bg-card,#fff);color:var(--text,#1a1a1a);border:1px solid var(--border,rgba(0,0,0,.12));' +
'box-shadow:0 12px 32px rgba(20,10,50,.18);font-size:13px;font-weight:400;line-height:1.4;text-align:left;' +
'pointer-events:none;opacity:0;visibility:hidden}' +
'.chc-card.chc-show{pointer-events:auto;visibility:visible}' +
'.chc-site{display:flex;align-items:center;gap:.5em;font-size:.85em;opacity:.7;margin-bottom:.35em}' +
'.chc-fav{flex:none;display:grid;place-items:center;width:1.5em;height:1.5em;border-radius:50%;color:#fff;font-size:.8em;font-weight:700;opacity:1}' +
'.chc-title{display:block;font-weight:650;margin-bottom:.25em;color:inherit;text-decoration:none}' +
'.chc-snip{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;opacity:.7;font-size:.92em}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var FAV = ['#2563eb', '#db2777', '#059669', '#d97706', '#7c3aed', '#0891b2', '#dc2626'];
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function host(c) {
if (c.dataset.site) return c.dataset.site;
var h = c.getAttribute('href') || c.dataset.href || '';
try { h = new URL(h, location.href).hostname; } catch (e) { /* keep as typed */ }
return h.replace(/^www\./, '') || 'source';
}
function hash(s) { var n = 0; for (var i = 0; i < s.length; i++) n = (n * 31 + s.charCodeAt(i)) | 0; return Math.abs(n); }
function setup(el) {
var d = el.dataset;
var inert = !!el.closest('[aria-hidden="true"]');
el.style.setProperty('--chc-c', d.color || DEFAULTS.color);
var seen = {}, sources = [], badges = [];
var uid = 'chc-' + Math.random().toString(36).slice(2, 8);
el.querySelectorAll('[data-cite]').forEach(function (c) {
var key = c.getAttribute('href') || c.dataset.href || c.dataset.site || c.dataset.title || String(badges.length);
var src = seen[key];
if (!src) {
src = seen[key] = { n: sources.length + 1, site: host(c), title: c.dataset.title || '', snippet: c.dataset.snippet || '', href: c.getAttribute('href') || c.dataset.href };
sources.push(src);
}
// Keep the badge on the same line as the word before it.
var prev = c.previousSibling;
if (prev && prev.nodeType === 3 && /\S$/.test(prev.textContent)) {
var m = prev.textContent.match(/^([\s\S]*?)(\S+)$/);
var glue = document.createElement('span');
glue.style.whiteSpace = 'nowrap';
prev.textContent = m[1];
glue.appendChild(document.createTextNode(m[2]));
c.parentNode.insertBefore(glue, c);
glue.appendChild(c);
}
c.classList.add('chc-badge');
c.textContent = src.n;
if (!inert) {
if (c.tagName !== 'A' && c.tagName !== 'BUTTON') c.tabIndex = 0;
c.setAttribute('aria-label', 'Source ' + src.n + ': ' + (src.title || src.site));
c.setAttribute('aria-describedby', uid);
}
badges.push({ el: c, src: src });
});
var card = document.createElement('div');
card.className = 'chc-card';
card.id = uid;
card.setAttribute('role', 'tooltip');
el.appendChild(card);
var st = el._chc = { badges: badges, card: card, open: null, timers: [], auto: d.autoplay !== undefined, user: false, idx: -1, hideT: 0 };
badges.forEach(function (b) {
var c = b.el;
c.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { stopAuto(el); show(el, b); } });
c.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') hideSoon(el); });
c.addEventListener('focus', function () { stopAuto(el); show(el, b); });
c.addEventListener('blur', function () { hideSoon(el); });
c.addEventListener('click', function (e) {
// On touch, the first tap opens the card; a second tap follows the link.
if (st.open !== b) { e.preventDefault(); stopAuto(el); show(el, b); }
});
});
card.addEventListener('pointerenter', function () { clearTimeout(st.hideT); });
card.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') hideSoon(el); });
document.addEventListener('pointerdown', function (e) {
if (st.user && st.open && !el.contains(e.target)) hide(el);
});
window.addEventListener('resize', function () { if (st.open) place(el, st.open); });
}
function fill(card, src) {
var col = FAV[hash(src.site) % FAV.length];
var title = src.title || src.site;
card.innerHTML = '<span class="chc-site"><span class="chc-fav" style="background:' + col + '"></span><span></span></span>' +
(src.href && !card.closest('[aria-hidden="true"]') ? '<a class="chc-title"></a>' : '<span class="chc-title"></span>') +
(src.snippet ? '<span class="chc-snip"></span>' : '');
card.querySelector('.chc-fav').textContent = src.site.charAt(0).toUpperCase();
card.querySelector('.chc-site span:last-child').textContent = src.n + ' · ' + src.site;
var t = card.querySelector('.chc-title');
t.textContent = title;
if (t.tagName === 'A') t.href = src.href;
if (src.snippet) card.querySelector('.chc-snip').textContent = src.snippet;
}
// Put the card just below the badge (or above if there's more room there),
// kept inside the container's width.
function place(el, b) {
var st = el._chc, card = st.card;
var er = el.getBoundingClientRect(), br = b.el.getBoundingClientRect();
var w = card.offsetWidth, h = card.offsetHeight;
var left = br.left - er.left + br.width / 2 - w / 2;
left = Math.max(4, Math.min(el.clientWidth - w - 4, left));
var below = br.bottom - er.top + 8;
var above = br.top - er.top - h - 8;
var down;
if (st.fitInside) {
// In a demo box: stay inside it, overflowing as little as possible.
down = below + h <= el.clientHeight || (above < 0 && below + h - el.clientHeight < -above);
} else {
// On a page: open downwards unless the window runs out of room.
down = window.innerHeight - br.bottom > h + 16 || br.top < h + 16;
}
card.style.left = left + 'px';
card.style.top = (down ? below : above) + 'px';
return down;
}
function show(el, b) {
var st = el._chc;
clearTimeout(st.hideT);
if (st.open === b) return;
st.badges.forEach(function (x) { x.el.classList.toggle('chc-on', x.src === b.src); });
st.open = b;
var card = st.card;
fill(card, b.src);
card.classList.add('chc-show');
var down = place(el, b);
if (reduced()) { anime.utils.set(card, { opacity: 1, y: 0, scale: 1 }); return; }
anime.animate(card, { opacity: [0, 1], y: [down ? -8 : 8, 0], scale: [0.96, 1], duration: 320, ease: 'out(3)' });
}
function hide(el) {
var st = el._chc;
if (!st.open) return;
st.open = null;
st.badges.forEach(function (x) { x.el.classList.remove('chc-on'); });
var card = st.card;
anime.animate(card, {
opacity: 0, y: 4, duration: reduced() ? 0 : 200, ease: 'in(2)',
onComplete: function () { if (!st.open) card.classList.remove('chc-show'); }
});
}
function hideSoon(el) {
var st = el._chc;
clearTimeout(st.hideT);
st.hideT = setTimeout(function () { hide(el); }, 220);
}
function stopAuto(el) {
var st = el._chc;
st.user = true;
st.timers.forEach(clearTimeout);
st.timers = [];
}
function popIn(el, delay) {
var bs = el._chc.badges.map(function (b) { return b.el; });
anime.utils.set(bs, { scale: 0, opacity: 0 });
anime.animate(bs, { scale: [0, 1], opacity: [0, 1], duration: 700, delay: anime.stagger(260, { start: delay || 0 }), ease: 'outElastic(1, .55)' });
return (delay || 0) + bs.length * 260 + 500;
}
function autoStep(el) {
var st = el._chc, d = el.dataset;
if (st.user) return;
var interval = +d.interval || DEFAULTS.interval;
var uniq = [];
st.badges.forEach(function (b) { if (uniq.indexOf(b.src) < 0) uniq.push(b); });
st.idx++;
if (st.idx >= uniq.length) {
st.idx = -1;
hide(el);
var wait = 900;
if (d.loop !== undefined) wait = popIn(el, 500);
st.timers.push(setTimeout(function () { autoStep(el); }, wait));
return;
}
show(el, uniq[st.idx]);
st.timers.push(setTimeout(function () {
hide(el);
st.timers.push(setTimeout(function () { autoStep(el); }, 450));
}, interval));
}
function play(el) {
var st = el._chc;
var wait = popIn(el, 200);
if (st.auto) st.timers.push(setTimeout(function () { autoStep(el); }, wait + 300));
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-citation-hover-cards]').forEach(function (el) {
if (el._chc) return;
setup(el);
el._chc.fitInside = !!el.closest('[aria-hidden="true"]') || el.dataset.autoplay !== undefined;
if (reduced()) return;
anime.utils.set(el._chc.badges.map(function (b) { return b.el; }), { scale: 0, opacity: 0 });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p data-citation-hover-cards>
An octopus has three hearts<a href="https://en.wikipedia.org/wiki/Octopus" data-cite
data-title="Octopus" data-snippet="Two hearts pump blood through the gills…"></a>
and blue blood.
</p>