← Broadcast

Quote Card Reveal

A pull-quote that builds itself for a video or a slide. A big opening quotation mark drops in and settles, the words fade up out of a blur one after another, the closing mark lands, then a short rule draws out and the name slides in beside it.

The best way to predict the future is to invent it.
Alan Kay

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-quote-card-reveal to a <figure> holding a <blockquote> and a <figcaption>. Leave the quotation marks out of the text; it adds its own.
  3. Size it with font-size; the quote is 1.45 times that.

Options

Add any of these attributes to the same element.

data-colorColour of the quotation marks and the rule. Default #a855f7.
data-staggerGap between words. Default 70 ms.
data-loopFade away and build again.
data-holdHow long it stays built before looping. Default 3000 ms.
data-delayWait before it starts. Default 200 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Quote Card Reveal: a pull-quote that builds itself for a video or slide.
    // A large opening quotation mark drops in, the quote fades up word by
    // word, the closing mark lands, then a rule draws out and the attribution
    // slides in beside it.
    // Usage: <figure data-quote-card-reveal><blockquote>Stay curious.</blockquote><figcaption>Ada Lovelace</figcaption></figure>
    // The first child is the quote, the second (optional) the attribution.
    // Size it with font-size.
    // Optional: data-color (quotation marks and rule, default #a855f7),
    // data-stagger (ms between words, default 70), data-loop (fade away and
    // build again), data-hold (ms it stays built before looping, default
    // 3000), data-delay (ms before it starts, default 200).
    // Script use: animacious.quoteCardReveal(el) builds it again.
    var DEFAULTS = { color: '#a855f7', stagger: 70, hold: 3000, delay: 200 };

    var css =
      '.qcr{position:relative;display:block;margin:0;padding:0.9em 1.3em 1em;text-align:left;line-height:1.3;--qcr-c:' + DEFAULTS.color + '}' +
      '.qcr-mark{font-family:Georgia,"Times New Roman",serif;font-weight:700;color:var(--qcr-c);pointer-events:none}' +
      '.qcr-open{position:absolute;left:-0.06em;top:-0.18em;font-size:4.6em;line-height:1}' +
      '.qcr-close{display:inline-block;font-size:1.9em;line-height:0;vertical-align:-0.5em;margin-left:0.04em}' +
      '.qcr-quote{position:relative;margin:0;padding:0.35em 0 0;font-size:1.45em;font-weight:700;letter-spacing:-0.01em}' +
      '.qcr-w{display:inline-block;white-space:pre}' +
      '.qcr-by{display:flex;align-items:center;gap:0.7em;margin-top:0.9em;font-size:0.85em}' +
      '.qcr-rule{flex:none;width:2.4em;height:0.14em;border-radius:1em;background:var(--qcr-c);transform-origin:0 50%}' +
      '.qcr-name{font-weight:600;letter-spacing:0.04em;opacity:.8}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(el, k) { var v = el.dataset[k]; return v !== undefined && v !== '' ? +v : DEFAULTS[k]; }

    function setup(el) {
      var d = el.dataset;
      var kids = Array.prototype.slice.call(el.children);
      var quote = (kids[0] || el).textContent.trim().replace(/^["“]+|["”]+$/g, '');
      var by = kids[1] ? kids[1].textContent.trim().replace(/^[-–—\s]+/, '') : '';
      el.classList.add('qcr');
      el.style.setProperty('--qcr-c', d.color || DEFAULTS.color);
      el.setAttribute('aria-label', '“' + quote + '”' + (by ? ', ' + by : ''));
      el.setAttribute('role', 'figure');
      el.textContent = '';

      var open = mk('span', 'qcr-mark qcr-open', '“');
      var close = mk('span', 'qcr-mark qcr-close', '”');
      var q = mk('p', 'qcr-quote');
      var words = quote.split(/\s+/).map(function (w, i, all) {
        var sp = mk('span', 'qcr-w', w + (i < all.length - 1 ? ' ' : ''));
        q.appendChild(sp);
        return sp;
      });
      // The closing mark hangs off the last word so it sits right after the text.
      words[words.length - 1].appendChild(close);
      [open, q].forEach(function (n) { n.setAttribute('aria-hidden', 'true'); el.appendChild(n); });
      var row = null, rule = null, name = null;
      if (by) {
        row = mk('div', 'qcr-by');
        rule = mk('span', 'qcr-rule');
        name = mk('span', 'qcr-name', by);
        row.appendChild(rule);
        row.appendChild(name);
        row.setAttribute('aria-hidden', 'true');
        el.appendChild(row);
      }
      el._qcr = { open: open, close: close, words: words, rule: rule, name: name };
      if (!reduced()) hide(el);
    }

    function mk(tag, cls, text) {
      var n = document.createElement(tag);
      n.className = cls;
      if (text) n.textContent = text;
      return n;
    }

    function hide(el) {
      var s = el._qcr;
      anime.utils.set(s.open, { opacity: 0, y: '-0.35em', scale: 1.6, rotate: -12 });
      anime.utils.set(s.close, { opacity: 0, y: '0.2em', scale: 0.4 });
      anime.utils.set(s.words, { opacity: 0, y: '0.45em', filter: 'blur(6px)' });
      if (s.rule) anime.utils.set(s.rule, { scaleX: 0 });
      if (s.name) anime.utils.set(s.name, { opacity: 0, x: '-0.8em' });
    }

    function play(el) {
      var s = el._qcr;
      if (s.tl) s.tl.pause();
      clearTimeout(s.wait);
      hide(el);
      var loop = el.dataset.loop !== undefined && el.dataset.loop !== 'false';
      var st = num(el, 'stagger');
      var wordsEnd = 350 + st * s.words.length + 500;

      var tl = anime.createTimeline({ delay: num(el, 'delay') });
      tl.add(s.open, { opacity: [0, 1], y: ['-0.35em', '0em'], scale: [1.6, 1], rotate: [-12, 0], duration: 750, ease: 'outBack(2.2)' })
        .add(s.words, { opacity: [0, 1], y: ['0.45em', '0em'], filter: ['blur(6px)', 'blur(0px)'], duration: 600, ease: 'out(3)', delay: anime.stagger(st) }, 350)
        .add(s.close, { opacity: [0, 1], y: ['0.2em', '0em'], scale: [0.4, 1], duration: 600, ease: 'outBack(2.6)' }, wordsEnd - 250);
      if (s.rule) {
        tl.add(s.rule, { scaleX: [0, 1], duration: 550, ease: 'inOut(3)' }, wordsEnd)
          .add(s.name, { opacity: [0, 1], x: ['-0.8em', '0em'], duration: 550, ease: 'out(3)' }, wordsEnd + 300);
      }
      if (loop) {
        var out = wordsEnd + 800 + num(el, 'hold');
        var all = [s.open, s.close].concat(s.words, s.rule ? [s.rule, s.name] : []);
        tl.add(all, { opacity: 0, y: '-0.3em', duration: 450, ease: 'in(2)', delay: anime.stagger(12) }, out);
        tl.then(function () { s.wait = setTimeout(function () { if (s.visible) play(el); else s.pending = true; }, 500); });
      }
      s.tl = tl;
    }

    window.animacious = window.animacious || {};
    window.animacious.quoteCardReveal = function (el) {
      if (!el || !el._qcr || reduced()) return;
      play(el);
    };

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._qcr;
          s.visible = e.isIntersecting;
          if (!e.isIntersecting) return;
          if (!s.started) { s.started = true; play(e.target); }
          else if (s.pending) { s.pending = false; play(e.target); }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-quote-card-reveal]').forEach(function (el) {
        if (el._qcr) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-quote-card-reveal>
  <blockquote>The best way to predict the future is to invent it.</blockquote>
  <figcaption>Alan Kay</figcaption>
</figure>