Testimonial Columns
Your testimonials are shared out into columns that scroll on without end, each at its own speed, fading out at the top and bottom. Point at it and it slows to a stop, so a quote can be read.
“It took ten minutes to add and our landing page finally feels alive.”
“Copy, paste, done. I didn’t have to learn a single new tool.”
“Subtle in the right way. Our clients noticed straight away.”
“Motion that works on phones as well as it does on my big screen.”
“The reduced-motion support alone saved me an afternoon.”
“I swapped three libraries for a few small scripts. Pages load faster too.”
“Our sign-ups went up the week we added the hero animation.”
“Readable code I can actually tweak. Rare, and lovely.”
“Every demo works on my old phone. That never happens.”
How to use it
- Paste the snippet just before
</head>. - Add
data-testimonial-columnsto a box with a height, holding your testimonials. - They’re dealt into columns in turn; the number of columns follows the width, so a phone gets fewer.
Options
Add any of these attributes to the same element.
data-columns | Most columns. Default 3. |
data-min | Narrowest a column may be, in px. Default 220. |
data-speed | Speed in px a second; each column goes a little faster or slower. Default 28. |
data-direction | up, down or alternate. Default up. |
data-gap | Space between cards and columns, in px. Default 16. |
data-fade="false" | No fading at the top and bottom. |
data-pause="false" | Keep moving under the mouse. |
Each column is filled with copies so it never runs out; the copies are hidden from screen readers. It only moves while on screen, and with reduced motion the columns stand still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Testimonial Columns: your testimonials are shared out into columns that
// scroll upwards (or alternately up and down) without end, each at its
// own speed, fading out at the top and bottom. A mouse over it slows it
// to a stop so a quote can be read.
// Usage: <div data-testimonial-columns style="height: 420px">
// <figure>…</figure> <figure>…</figure> …
// </div>
// Give it a height. The number of columns follows the width.
// Optional: data-columns (most columns, default 3), data-min (narrowest a
// column may be, px, default 220), data-speed (px a second, default 28),
// data-direction ("up", default, "down" or "alternate"), data-gap (px
// between cards and columns, default 16), data-fade="false" (no fading
// edges), data-pause="false" (keep moving under the mouse).
var DEFAULTS = { columns: 3, min: 220, speed: 28, gap: 16 };
var PACE = [1, 0.7, 1.28, 0.86];
var css =
'.tsc{display:flex;overflow:hidden;align-items:flex-start}' +
'.tsc.tsc-fade{-webkit-mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent);mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent)}' +
'.tsc-col{flex:1 1 0;min-width:0}' +
'.tsc-track{will-change:transform}' +
'.tsc-track>*{margin:0 0 var(--tsc-gap)!important}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var cards = Array.prototype.slice.call(el.children);
if (!cards.length) return;
cards.forEach(function (c) { el.removeChild(c); });
el.classList.add('tsc');
if (d.fade !== 'false') el.classList.add('tsc-fade');
var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
el.style.setProperty('--tsc-gap', gap + 'px');
el.style.gap = gap + 'px';
var s = el._tscS = { cards: cards, cols: [], n: 0, t: 0, last: 0, raf: 0, visible: false, hover: false, rate: 1 };
function build() {
var maxCols = parseInt(d.columns, 10) || DEFAULTS.columns;
var min = +d.min || DEFAULTS.min;
var n = Math.max(1, Math.min(maxCols, cards.length, Math.floor((el.clientWidth + gap) / (min + gap))));
if (n === s.n) { fill(); return; }
s.n = n;
el.textContent = '';
s.cols = [];
for (var k = 0; k < n; k++) {
var col = document.createElement('div');
col.className = 'tsc-col';
var track = document.createElement('div');
track.className = 'tsc-track';
col.appendChild(track);
el.appendChild(col);
var dir = d.direction === 'down' ? -1 : d.direction === 'alternate' && k % 2 ? -1 : 1;
s.cols.push({ track: track, own: [], dir: dir, pace: PACE[k % PACE.length], setH: 0 });
}
cards.forEach(function (c, i) {
var col = s.cols[i % n];
col.own.push(c);
});
fill();
}
// Each column holds its own cards, then copies of them until it is
// tall enough to scroll on without a gap.
function fill() {
var H = el.clientHeight;
s.cols.forEach(function (col) {
col.track.textContent = '';
col.own.forEach(function (c) { col.track.appendChild(c); });
col.setH = col.track.offsetHeight;
if (reduced() || !col.setH) return;
var copies = Math.ceil(H / col.setH) + 1;
for (var r = 0; r < copies; r++) {
col.own.forEach(function (c) {
var copy = c.cloneNode(true);
copy.setAttribute('aria-hidden', 'true');
copy.removeAttribute('id');
col.track.appendChild(copy);
});
}
});
draw();
}
function draw() {
s.cols.forEach(function (col, k) {
if (!col.setH || reduced()) return;
var off = (s.t * col.pace + k * 97) % col.setH;
var y = col.dir > 0 ? -off : -(col.setH - off);
col.track.style.transform = 'translate3d(0,' + y.toFixed(2) + 'px,0)';
});
}
function frame(now) {
s.raf = 0;
if (!s.visible) return;
var dt = s.last ? Math.min(64, now - s.last) : 0;
s.last = now;
s.rate += ((s.hover ? 0 : 1) - s.rate) * Math.min(1, dt / 300);
s.t += dt / 1000 * (+d.speed || DEFAULTS.speed) * s.rate;
draw();
s.raf = requestAnimationFrame(frame);
}
build();
if (document.fonts) document.fonts.ready.then(fill);
var lastW = el.clientWidth, lastH = el.clientHeight;
new ResizeObserver(function () {
if (el.clientWidth === lastW && el.clientHeight === lastH) return;
lastW = el.clientWidth; lastH = el.clientHeight;
build();
}).observe(el);
if (reduced()) return;
if (d.pause !== 'false') {
el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') s.hover = true; });
el.addEventListener('pointerleave', function () { s.hover = false; });
}
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible && !s.raf) { s.last = 0; s.raf = requestAnimationFrame(frame); }
}).observe(el);
}
function init() {
document.querySelectorAll('[data-testimonial-columns]').forEach(function (el) {
if (el._tsc) return;
el._tsc = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-testimonial-columns data-direction="alternate" style="height: 420px">
<figure>
<blockquote>"Copy, paste, done."</blockquote>
<figcaption>Ben Kato</figcaption>
</figure>
<!-- … six or more -->
</div>