← AI

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.

An octopus has three hearts and blue, copper-based blood. Around two thirds of its neurons sit in its arms, so each arm can explore on its own.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-citation-hover-cards to the box holding the answer.
  3. Where each source is cited, add an empty <a href="…" data-cite></a> with data-title and data-snippet for the card. It becomes the numbered badge.

Options

Add any of these attributes to the same element.

data-citeOn each citation. A link’s address gives the site name; on a <span>, use data-href or data-site.
data-titleOn each citation: the page title for its card.
data-snippetOn each citation: a line or two quoted from the source.
data-autoplayOpen each card in turn by itself, until someone hovers or taps a badge.
data-intervalHow long each autoplay card stays open. Default 2200 ms.
data-loopPop the badges in again each time the autoplay goes round.
data-colorBadge 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.

1 · Snippet
<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>
2 · Your HTML
<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>