Swipe Hint
A hand presses on a sideways-scrolling row and swipes across it, nudging the row along as it goes, so people know there’s more to see. It does this a couple of times and bows out for good as soon as someone touches the row.
Lakes
Dunes
Studio
Night
Forest
Desert
How to use it
- Paste the snippet just before
</head>. - Add
data-swipe-hintto a row withoverflow-x: auto.
Options
Add any of these attributes to the same element.
data-repeat | How many swipes. Default 2. |
data-loop | Keep swiping while in view, until someone touches the row. |
data-delay | Pause between swipes. Default 1400 ms. |
data-nudge | How far the row moves with the hand. Default 56 px; 0 leaves it still. |
data-direction | left (default) swipes leftwards to show more on the right; right the reverse. |
data-color | The hand’s fill. Default white. |
data-label | A short caption under the hand, like Swipe. |
It waits until the row is half in view, and only shows when the row really can scroll. The hand ignores the pointer, so the row stays usable. With reduced motion there’s no hint.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Swipe Hint: a hand icon presses on a sideways-scrolling row and swipes
// across it, nudging the row along as it goes, to show that there's more
// to see. It does this a couple of times, then bows out for good as soon as
// someone scrolls or touches the row.
// Usage: <div data-swipe-hint style="overflow-x:auto">…cards…</div>
// Optional: data-repeat (how many swipes, default 2), data-loop (keep
// swiping while in view until someone touches the row), data-delay (pause
// between swipes, ms, default 1400), data-nudge (how far the row moves with
// the hand, px, default 56, 0 to leave it still), data-direction ("left",
// the default, swipes leftwards to show more on the right; "right" for the
// reverse), data-color (the hand's fill, default white), data-label (a
// short caption under the hand, e.g. "Swipe").
var DEFAULTS = { repeat: 2, delay: 1400, nudge: 56 };
var HAND =
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 13.2V4.1a2 2 0 0 1 4 0v6.8l4.7 1a2.2 2.2 0 0 1 1.8 2.6l-.9 5.1A3 3 0 0 1 15.6 22h-4.3a3 3 0 0 1-2.4-1.2L5 15.7a1.7 1.7 0 0 1 2.5-2.3Z"' +
' fill="currentColor" stroke="#1e1b4b" stroke-width="1.4" stroke-linejoin="round"/></svg>';
var css =
'.swh-wrap{position:relative}' +
'.swh-hint{position:absolute;top:50%;left:0;z-index:2;display:flex;flex-direction:column;align-items:center;gap:4px;pointer-events:none;opacity:0;translate:-50% -50%}' +
'.swh-dot{position:absolute;top:-2px;left:50%;width:22px;height:22px;margin-left:-13px;border-radius:50%;background:rgba(255,255,255,.55);box-shadow:0 0 0 6px rgba(255,255,255,.22);opacity:0}' +
'.swh-trail{position:absolute;top:4px;height:10px;border-radius:99px;background:linear-gradient(90deg,rgba(255,255,255,0),rgba(255,255,255,.75));opacity:0}' +
'.swh-hand{position:relative;display:block;width:46px;height:46px;color:#fff;filter:drop-shadow(0 4px 10px rgba(15,15,30,.35));transform-origin:40% 20%}' +
'.swh-hand svg{display:block;width:100%;height:100%}' +
'.swh-label{font:700 12px/1 system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:#fff;background:rgba(30,27,75,.75);padding:5px 9px;border-radius:99px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function setup(el) {
var d = el.dataset;
var repeat = Math.max(1, num(d.repeat, DEFAULTS.repeat));
var loop = 'loop' in d;
var delay = Math.max(0, num(d.delay, DEFAULTS.delay));
var nudge = Math.max(0, num(d.nudge, DEFAULTS.nudge));
var dir = d.direction === 'right' ? -1 : 1; // 1: the hand moves left, the row moves on
var wrap = document.createElement('div');
wrap.className = 'swh-wrap';
el.parentNode.insertBefore(wrap, el);
wrap.appendChild(el);
var hint = document.createElement('div');
hint.className = 'swh-hint';
hint.setAttribute('aria-hidden', 'true');
hint.innerHTML = '<i class="swh-trail"></i><i class="swh-dot"></i><span class="swh-hand">' + HAND + '</span>' +
(d.label ? '<span class="swh-label"></span>' : '');
if (d.label) hint.querySelector('.swh-label').textContent = d.label;
wrap.appendChild(hint);
var hand = hint.querySelector('.swh-hand');
var dot = hint.querySelector('.swh-dot');
var trail = hint.querySelector('.swh-trail');
if (d.color) hand.style.color = d.color;
var done = false, count = 0, nudging = false, tl = null, timer = null, inView = false;
var expected = el.scrollLeft; // where our own nudges leave the row
function quit() {
if (done) return;
done = true;
clearTimeout(timer);
if (tl) tl.pause();
nudging = false;
anime.animate(hint, { opacity: 0, duration: 250, ease: 'out(2)' });
}
['pointerdown', 'wheel'].forEach(function (t) { el.addEventListener(t, quit, { passive: true }); });
el.addEventListener('touchstart', quit, { passive: true });
el.addEventListener('scroll', function () { if (Math.abs(el.scrollLeft - expected) > 3) quit(); }, { passive: true });
function swipe() {
if (done || !inView) return;
var room = el.scrollWidth - el.clientWidth;
if (room < 4) { timer = setTimeout(swipe, 1500); return; } // nothing to scroll (yet)
var w = el.clientWidth;
var from = w * (dir > 0 ? 0.7 : 0.3), to = w * (dir > 0 ? 0.3 : 0.7);
var span = Math.abs(to - from);
var base = expected = el.scrollLeft;
var push = Math.min(nudge, dir > 0 ? room - base : base) * dir;
var row = { v: 0 };
trail.style.width = '0px';
trail.style.left = dir > 0 ? '50%' : 'auto';
trail.style.right = dir > 0 ? 'auto' : '50%';
anime.utils.set(hint, { x: from, opacity: 0 });
anime.utils.set(hand, { scale: 1, rotate: dir * -8 });
nudging = true;
tl = anime.createTimeline({
onComplete: function () {
nudging = false;
count++;
if (!done && (loop || count < repeat)) timer = setTimeout(swipe, delay);
else if (!loop) quit();
},
});
tl.add(hint, { opacity: 1, duration: 260, ease: 'out(2)' }, 0)
.add(hand, { scale: 0.86, duration: 220, ease: 'out(3)' }, 280)
.add(dot, { opacity: [0, 1], scale: [0.4, 1], duration: 220, ease: 'out(3)' }, 300)
.add(hint, { x: to, duration: 760, ease: 'inOut(3)' }, 520)
.add(hand, { rotate: dir * 8, duration: 760, ease: 'inOut(3)' }, 520)
.add(trail, { width: [0, span * 0.8], opacity: [{ to: 1, duration: 200 }, { to: 0, duration: 560 }], duration: 760, ease: 'out(2)' }, 520)
.add(row, {
v: push,
duration: 760,
ease: 'inOut(3)',
onUpdate: function () { expected = base + row.v; el.scrollLeft = expected; },
}, 520)
.add(dot, { opacity: 0, scale: 1.6, duration: 260, ease: 'out(2)' }, 1280)
.add(hand, { scale: 1, duration: 260, ease: 'out(2)' }, 1280)
.add(hint, { opacity: 0, duration: 300, ease: 'in(2)' }, 1500)
.add(row, {
v: 0,
duration: 700,
ease: 'inOut(3)',
onUpdate: function () { expected = base + row.v; el.scrollLeft = expected; },
}, 1450);
}
new IntersectionObserver(function (es) {
inView = es[0].isIntersecting;
clearTimeout(timer);
if (inView && !done && !nudging) timer = setTimeout(swipe, 500);
}, { threshold: 0.5 }).observe(wrap);
}
function init() {
if (reduced()) return; // no motion: no hint, the row still scrolls as usual
document.querySelectorAll('[data-swipe-hint]').forEach(function (el) {
if (el._swh) return;
el._swh = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="cards" data-swipe-hint data-label="Swipe" style="overflow-x: auto">
<div class="card">…</div>
<div class="card">…</div>
</div>