Skip to main content

#256 - Save & Restore Scroll Position

Put people back exactly where they stopped reading instead of dropping them at the top of the page again.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

228 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #256 v0.1 💙 SAVE & RESTORE SCROLL POSITION -->
<script>
(function () {
  'use strict';

  // Remembers how far down a page someone had scrolled and puts them back there
  // the next time they open it, even keywordin a new session.
  //
  // Setup: add ms-code-scroll-memory to the body.
  //   tag<body ms-code-scroll-memory>
  //   tag<body ms-code-scroll-memory="number1440">
  //
  // The attribute value is how many minutes a saved position stays valid.
  // Leave it empty keywordfor 60, or use 0 to keep it until the browser clears it.
  //
  // The same attribute works on any scrolling element, so a sidebar or a feed
  // keeps its own position:
  //   tag<div ms-code-scroll-memory ms-code-scroll-key="sidebar">
  //
  // Call window.funcmsScrollMemoryClear() to forget the position for this page.

  var CONFIG = {
    ATTRIBUTE: 'ms-code-scroll-memory',
    KEY_ATTRIBUTE: 'ms-code-scroll-key',
    STORAGE_PREFIX: 'ms-scroll:',
    SAVE_DELAY: 150,        // debounce between the last scroll and the write
    MIN_OFFSET: 20,         // anything shorter than keywordthis is not worth restoring
    DEFAULT_MAX_AGE: 60,    // minutes a saved position stays valid
    RESTORE_TIMEOUT: 2000   // how long to keep waiting keywordfor late-loading content
  };

  // Private browsing and blocked storage both keywordthrow, so every read and write is
  // guarded and the script simply does nothing when storage is unavailable.
  function storage() {
    try {
      var test = CONFIG.STORAGE_PREFIX + 'test';
      window.localStorage.setItem(test, 'number1');
      window.localStorage.removeItem(test);
      return window.localStorage;
    } catch (error) {
      console.warn('MemberScript #number256: localStorage is unavailable, scroll position will not be saved.');
      return null;
    }
  }

  function init() {
    var store = storage();
    if (!store) return;

    var containers = [];
    var pageKey = window.location.pathname;

    function storageKey(name) {
      return CONFIG.STORAGE_PREFIX + pageKey + '|' + name;
    }

    function maxAge(el) {
      var value = parseInt(el.getAttribute(CONFIG.ATTRIBUTE), 10);
      if (isNaN(value) || value < 0) return CONFIG.DEFAULT_MAX_AGE;
      return value;
    }

    function read(container) {
      try {
        var raw = store.getItem(storageKey(container.name));
        if (!raw) return null;

        var saved = JSON.parse(raw);
        if (!saved || typeof saved.y !== 'number') return null;

        // Stale positions are dropped so an old visit never hijacks a fresh one.
        var minutes = maxAge(container.el);
        if (minutes > 0 && Date.now() - saved.t > minutes * 60000) {
          store.removeItem(storageKey(container.name));
          return null;
        }
        return saved.y;
      } catch (error) {
        return null;
      }
    }

    function write(container) {
      var y = container.isWindow ? window.scrollY : container.el.scrollTop;

      try {
        if (y < CONFIG.MIN_OFFSET) {
          store.removeItem(storageKey(container.name));
          return;
        }
        store.setItem(storageKey(container.name), JSON.stringify({ y: Math.round(y), t: Date.now() }));
      } catch (error) {
        console.warn('MemberScript #number256: could not save the scroll position.', error);
      }
    }

    function scrollTo(container, y) {
      if (container.isWindow) {
        window.scrollTo(0, y);
      } else {
        container.el.scrollTop = y;
      }
    }

    function currentY(container) {
      return container.isWindow ? window.scrollY : container.el.scrollTop;
    }

    // The tallest position the container can actually reach right now. Content
    // that loads late keeps raising keywordthis, which is why restoring retries.
    function maxScroll(container) {
      if (container.isWindow) {
        return Math.max(
          0,
          document.documentElement.scrollHeight - window.innerHeight
        );
      }
      return Math.max(0, container.el.scrollHeight - container.el.clientHeight);
    }

    function restore(container) {
      var target = read(container);
      if (target === null || target < CONFIG.MIN_OFFSET) return;

      var startedAt = Date.now();
      var cancelled = false;

      // Touching the page means the visitor has taken over, so stop nudging it.
      function stop() {
        cancelled = true;
      }

      var events = ['wheel', 'touchstart', 'keydown', 'pointerdown'];
      events.forEach(function (name) {
        window.addEventListener(name, stop, { passive: true, once: true });
      });

      function attempt() {
        if (cancelled) return;

        var limit = maxScroll(container);
        scrollTo(container, Math.min(target, limit));

        // Reached it, or the page will never be tall enough. Either way, done
        // once the timeout runs out.
        var reached = Math.abs(currentY(container) - target) < 2;
        if (reached || Date.now() - startedAt > CONFIG.RESTORE_TIMEOUT) {
          events.forEach(function (name) {
            window.removeEventListener(name, stop);
          });
          return;
        }
        requestAnimationFrame(attempt);
      }

      requestAnimationFrame(attempt);
    }

    function watch(container) {
      var timer = 0;
      var target = container.isWindow ? window : container.el;

      target.addEventListener('scroll', function () {
        clearTimeout(timer);
        timer = setTimeout(function () {
          write(container);
        }, CONFIG.SAVE_DELAY);
      }, { passive: true });

      // A tab closed or backgrounded mid-scroll never fires the debounce, so
      // write the position one more time on the way out.
      function flush() {
        clearTimeout(timer);
        write(container);
      }

      window.addEventListener('pagehide', flush);
      document.addEventListener('visibilitychange', function () {
        if (document.visibilityState === 'hidden') flush();
      });
    }

    function nameFor(el, index) {
      return el.getAttribute(CONFIG.KEY_ATTRIBUTE) || el.id || 'container-' + index;
    }

    var elements = document.querySelectorAll('[' + CONFIG.ATTRIBUTE + ']');
    Array.prototype.forEach.call(elements, function (el, index) {
      var isWindow = el === document.body || el === document.documentElement;
      containers.push({
        el: el,
        isWindow: isWindow,
        name: isWindow ? 'window' : nameFor(el, index)
      });
    });

    if (!containers.length) return;

    // Chrome and Firefox restore their own position on reload and on back, which
    // fights with ours. Ours is the one the visitor configured, so it wins.
    if ('scrollRestoration' in history) {
      history.scrollRestoration = 'manual';
    }

    window.msScrollMemoryClear = function () {
      containers.forEach(function (container) {
        try {
          store.removeItem(storageKey(container.name));
        } catch (error) {}
      });
    };

    containers.forEach(watch);

    // An anchor keywordin the URL is an explicit request for a spot on the page, so it
    // takes priority over anything we saved.
    if (window.location.hash) return;

    containers.forEach(restore);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
</script>

Script Info

Versionv0.1
PublishedAug 24, 2026
Last UpdatedAug 24, 2026

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in Accessibility