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.
How to use it
- Paste the snippet just before
</head>. - Add
data-quote-card-revealto a<figure>holding a<blockquote>and a<figcaption>. Leave the quotation marks out of the text; it adds its own. - Size it with
font-size; the quote is 1.45 times that.
Options
Add any of these attributes to the same element.
data-color | Colour of the quotation marks and the rule. Default #a855f7. |
data-stagger | Gap between words. Default 70 ms. |
data-loop | Fade away and build again. |
data-hold | How long it stays built before looping. Default 3000 ms. |
data-delay | Wait before it starts. Default 200 ms. |
<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><figure data-quote-card-reveal>
<blockquote>The best way to predict the future is to invent it.</blockquote>
<figcaption>Alan Kay</figcaption>
</figure>