← Gestures

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.

Inbox
MiaPhotos from the trip
LeoRunning five late
AvaDraft attached
SamTickets booked!
ZoeCoffee tomorrow?
KaiPushed the fix

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pull-to-refresh to a scrolling box (a fixed height and overflow: auto).
  3. Listen for its refresh event and load your new content. Call e.preventDefault() to keep the spinner going, then e.detail.done() when you’re finished.

Options

Add any of these attributes to the same element.

data-thresholdHow far to pull before letting go refreshes, in px. Default 64.
data-durationHow long the spinner spins when nothing handles the event. Default 1400 ms.
data-colorSpinner colour. Default #6a0dad.
data-shuffleAfter each refresh the last item slides in at the top, as if new. For demos.
data-autoplayA 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.

1 · Snippet
<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>
2 · Your HTML
<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>