Pull to Refresh
At the top of a list, pull down and the content follows with growing resistance while a spinner drops in, its ring filling and turning as you pull. Let go past the line and it spins while the list refreshes; let go early and it springs back.
MiaPhotos from the trip
LeoRunning five late
AvaDraft attached
SamTickets booked!
ZoeCoffee tomorrow?
KaiPushed the fix
How to use it
- Paste the snippet just before
</head>. - Add
data-pull-to-refreshto a scrolling box (a fixed height andoverflow: auto). - Listen for its
refreshevent and load your new content. Calle.preventDefault()to keep the spinner going, thene.detail.done()when you’re finished.
Options
Add any of these attributes to the same element.
data-threshold | How far to pull before letting go refreshes, in px. Default 64. |
data-duration | How long the spinner spins when nothing handles the event. Default 1400 ms. |
data-color | Spinner colour. Default #6a0dad. |
data-shuffle | After each refresh the last item slides in at the top, as if new. For demos. |
data-autoplay | A pretend finger pulls every so many ms while in view. Default 4200. A real pull pauses it. |
It only pulls when the box is scrolled to the very top, so ordinary scrolling is untouched. Mice can drag the list down too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pull to Refresh: at the top of a scrolling list, pull down and the content
// follows with growing resistance while a spinner drops in from above, its
// ring filling and turning as you pull. Let go past the line and it spins
// while the list refreshes, then tucks away; let go early and it springs back.
// Usage: <div data-pull-to-refresh style="height:400px;overflow:auto">…list…</div>
// The element is the scrolling box; its contents are what you pull.
// Optional: data-threshold (px of pull that triggers a refresh, default 64),
// data-duration (ms the spinner spins when nobody handles the event, default
// 1400), data-color (spinner colour, default #6a0dad), data-shuffle (after
// each refresh the last item moves to the top, as if new; for demos),
// data-autoplay (a pretend finger pulls every so many ms while in view,
// default 4200; a real pull pauses it).
// Events: the box fires "refresh" with detail.done. Call
// e.preventDefault() to keep spinning, then detail.done() when your data is in.
var DEFAULTS = { threshold: 64, duration: 1400, color: '#6a0dad', autoplay: 4200 };
var RING = 56.55; // circumference of the r=9 ring
var css =
'.ptf{overscroll-behavior-y:contain}' +
'.ptf-body{will-change:transform}' +
'.ptf-spin{position:absolute;left:50%;top:0;z-index:3;width:38px;height:38px;margin-left:-19px;border-radius:50%;' +
'display:grid;place-items:center;background:#fff;box-shadow:0 4px 14px rgba(15,15,30,.16);pointer-events:none;opacity:0}' +
'.ptf-spin svg{width:24px;height:24px;display:block}' +
'.ptf-spin circle{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;transform:rotate(-90deg);transform-origin:12px 12px}' +
'.ptf-busy svg{animation:ptf-turn .8s linear infinite}' +
'@keyframes ptf-turn{to{transform:rotate(360deg)}}' +
'.ptf-finger{position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;' +
'background:rgba(30,14,60,.16);border:2px solid rgba(30,14,60,.32);pointer-events:none;opacity:0;z-index:5}';
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 T = +d.threshold || DEFAULTS.threshold;
var thumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('ptf');
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var body = document.createElement('div');
body.className = 'ptf-body';
while (el.firstChild) body.appendChild(el.firstChild);
el.appendChild(body);
var spin = document.createElement('div');
spin.className = 'ptf-spin';
spin.setAttribute('aria-hidden', 'true');
spin.style.color = d.color || DEFAULTS.color;
spin.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-dasharray="' + RING + '" stroke-dashoffset="' + RING + '"/></svg>';
el.appendChild(spin);
var ring = spin.querySelector('circle');
var svg = spin.querySelector('svg');
var live = document.createElement('span');
live.setAttribute('role', 'status');
live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)';
el.appendChild(live);
var pull = 0, state = 'idle', userAt = -1e9; // idle | pulling | refreshing | settling
// How far things sit for a given pull (already eased by resistance).
function draw(p) {
pull = p;
var prog = Math.min(1, p / T);
anime.utils.set(body, { y: p });
anime.utils.set(spin, { y: Math.min(p, T * 1.4) - 44 + prog * 6, opacity: Math.min(1, p / (T * 0.5)), rotate: prog * 220 });
if (state !== 'refreshing') ring.setAttribute('stroke-dashoffset', RING * (1 - prog * 0.78));
}
function resist(raw) { return raw <= 0 ? 0 : raw / (1 + raw / 220); }
function tweenTo(p, ms, done) {
var o = { p: pull };
anime.animate(o, {
p: p,
duration: reduced() ? 0 : ms,
ease: 'out(3)',
onUpdate: function () { draw(o.p); },
onComplete: done,
});
}
function release() {
if (state !== 'pulling') return;
if (pull >= T) refresh();
else { state = 'settling'; tweenTo(0, 380, function () { state = 'idle'; }); }
}
function refresh() {
state = 'refreshing';
ring.setAttribute('stroke-dashoffset', RING * 0.25);
spin.classList.add('ptf-busy');
live.textContent = 'Refreshing';
tweenTo(T * 0.85, 300);
var finished = false;
function done() {
if (finished) return;
finished = true;
anime.animate(spin, { scale: 0, duration: reduced() ? 0 : 220, ease: 'in(2)' });
tweenTo(0, 420, function () {
spin.classList.remove('ptf-busy');
anime.utils.set(spin, { scale: 1, opacity: 0 });
state = 'idle';
live.textContent = 'Updated';
if (d.shuffle !== undefined && d.shuffle !== 'false') shuffle();
});
}
var ev = new CustomEvent('refresh', { cancelable: true, detail: { done: done } });
if (el.dispatchEvent(ev)) setTimeout(done, +d.duration || DEFAULTS.duration);
}
// Demo nicety: the last item slides in at the top as if it had just arrived.
function shuffle() {
var list = body.children.length === 1 && body.firstElementChild.children.length > 1 ? body.firstElementChild : body;
var items = Array.from(list.children);
if (items.length < 2) return;
var before = items.map(function (n) { return n.getBoundingClientRect().top; });
var last = items[items.length - 1];
list.insertBefore(last, items[0]);
if (reduced()) return;
items.forEach(function (n, i) {
if (n === last) {
anime.animate(n, { opacity: [0, 1], scale: [0.92, 1], duration: 500, ease: 'out(3)' });
return;
}
var dy = before[i] - n.getBoundingClientRect().top;
anime.animate(n, { y: [dy, 0], duration: 450, ease: 'out(3)' });
});
}
// Pulling: only from the very top, only downwards.
var startY = null;
function begin(y) {
if (state !== 'idle' || el.scrollTop > 0) return false;
startY = y;
return true;
}
function moveTo(y) {
if (startY === null) return false;
var raw = y - startY;
if (state === 'idle') {
if (raw <= 4) { if (raw < -4) startY = null; return false; }
state = 'pulling';
}
if (state !== 'pulling') return false;
draw(resist(raw));
return true;
}
function end() { startY = null; release(); }
if (!thumb) {
el.addEventListener('touchstart', function (e) {
userAt = performance.now();
begin(e.touches[0].clientY);
}, { passive: true });
el.addEventListener('touchmove', function (e) {
userAt = performance.now();
if (moveTo(e.touches[0].clientY) && e.cancelable) e.preventDefault();
}, { passive: false });
el.addEventListener('touchend', end);
el.addEventListener('touchcancel', end);
// Mouse and pen: drag the list down.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'touch' || e.button) return;
userAt = performance.now();
if (begin(e.clientY)) el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch' || startY === null) return;
userAt = performance.now();
if (moveTo(e.clientY)) e.preventDefault();
});
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'touch') end(); });
el.addEventListener('dragstart', function (e) { if (state === 'pulling') e.preventDefault(); });
}
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var every = +d.autoplay || DEFAULTS.autoplay;
var finger = document.createElement('span');
finger.className = 'ptf-finger';
finger.setAttribute('aria-hidden', 'true');
el.appendChild(finger);
var visible = false, timer = 0;
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(play, 500);
}).observe(el);
function play() {
if (!visible) return;
if (performance.now() - userAt < 5000 || state !== 'idle' || el.scrollTop > 0) { timer = setTimeout(play, 1000); return; }
var w = el.clientWidth, h = el.clientHeight;
var sy = Math.min(40, h * 0.2), reach = Math.min(170, h * 0.75);
var p = { y: sy };
anime.utils.set(finger, { x: w / 2 + 30, y: sy, scale: 1.3 });
anime.animate(finger, { opacity: 1, scale: 1, duration: 180, ease: 'out(2)' });
setTimeout(function () {
if (!begin(sy)) { timer = setTimeout(play, 800); return; }
anime.animate(p, {
y: sy + reach,
duration: 800,
ease: 'inOut(2)',
onUpdate: function () {
moveTo(p.y);
anime.utils.set(finger, { y: sy + pull });
},
onComplete: function () {
setTimeout(function () {
end();
anime.animate(finger, { opacity: 0, scale: 0.7, duration: 240, ease: 'out(2)' });
timer = setTimeout(play, every);
}, 150);
},
});
}, 200);
}
}
function init() {
document.querySelectorAll('[data-pull-to-refresh]').forEach(function (el) {
if (el._ptf) return;
el._ptf = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-pull-to-refresh style="height: 420px; overflow: auto">
<div class="message">…</div>
<div class="message">…</div>
</div>
<script>
document.querySelector('[data-pull-to-refresh]').addEventListener('refresh', (e) => {
e.preventDefault();
fetch('/messages').then(/* update the list */).finally(e.detail.done);
});
</script>