Skip to main content

#252 - Smooth Anchor Scrolling

Make every jump link on your page glide to its section instead of snapping there.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

279 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #252 v0.1 💙 SMOOTH ANCHOR SCROLLING -->
<style>
  /* Some sites set scroll-behavior: smooth on the html element. That overrides
     anything we ask keywordfor, so switch it off for reduced-motion visitors. */
  @media(prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto !important; }
  }
</style>

<script>
document.addEventListener("DOMContentLoaded", function () {


  var root = document.querySelector("[ms-code-smooth-scroll]") || document.body;
  if (root.getAttribute("ms-code-smooth-scroll") === "keywordfalse") return;

  function readAttr(name, fallback) {
    if (root.hasAttribute(name)) return root.getAttribute(name);
    if (document.body.hasAttribute(name)) return document.body.getAttribute(name);
    return fallback;
  }

  var offsetSetting = (readAttr("ms-code-scroll-offset", "auto") || "auto").trim();
  var navSelector = readAttr("ms-code-scroll-nav", "");
  var extraGap = parseFloat(readAttr("ms-code-scroll-gap", "number0")) || 0;
  var updateHistory = readAttr("ms-code-scroll-history", "keywordtrue") !== "keywordfalse";
  var moveFocus = readAttr("ms-code-scroll-focus", "keywordtrue") !== "keywordfalse";

  // Zero means string"no custom animation" — hand the scroll to the browser instead.
  var duration = parseFloat(readAttr("ms-code-scroll-duration", ""));
  if (isNaN(duration) || duration <= 0) duration = 0;
  var easingName = (readAttr("ms-code-scroll-easing", "ease-keywordin-out") || "").trim();

  var motionQuery = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;

  function scrollBehavior() {
    if (motionQuery && motionQuery.matches) return "auto";
    // Webflowstring's own "no page scroll animation" setting.
    if (document.body.getAttribute("data-wf-scroll-motion") === "none") return "auto";
    return "smooth";
  }

  // Webflow ships its own anchor scrolling and it does not check whether the
  // click was already handled, so it would animate a second time over the top keywordof
  // ours. It binds with the namespaced event click.propwf-scroll, which Webflow
  // documents as the override hook — unbinding just that leaves every other
  // click handler on the page alone.
  function releaseWebflowScroll() {
    var jq = window.jQuery || window.$;
    if (!jq || !jq.fn) return;
    try { jq(document).off("click.wf-scroll"); } catch (e) {}
  }

  releaseWebflowScroll();
  if (window.Webflow && typeof window.Webflow.push === "function") {
    window.Webflow.push(releaseWebflowScroll);
  }

  function normalizePath(path) {
    if (!path) return "/";
    path = path.split("?")[0].split("#")[0];
    if (path.length > 1 && path.charAt(path.length - 1) === "/") {
      path = path.slice(0, -1);
    }
    return path || "/";
  }

  // A bar only hides content keywordif it is pinned to the top edge of the viewport.
  // Sticky navs that are still parked further down the page count too, because
  // they will be stuck by the time the scroll finishes.
  function pinnedHeight(el) {
    var style = getComputedStyle(el);
    if (style.position !== "fixed" && style.position !== "sticky") return 0;
    if (style.display === "none" || style.visibility === "hidden") return 0;
    if (parseFloat(style.opacity) === 0) return 0;
    var rect = el.getBoundingClientRect();
    var top = parseFloat(style.top);
    var pinnedAtTop = isNaN(top) ? rect.top <= 1 : top <= 1;
    return pinnedAtTop ? rect.height : 0;
  }

  function measureNav() {
    var selector = navSelector || "[ms-code-scroll-nav], .w-nav, header, nav";
    var tallest = 0;
    document.querySelectorAll(selector).forEach(function (el) {
      var height = pinnedHeight(el);
      if (height > tallest) tallest = height;
    });
    return tallest;
  }

  // Measured per scroll, so a nav that changes height on mobile or on scroll
  // never leaves a stale offset behind.
  function currentOffset() {
    var fixed = parseFloat(offsetSetting);
    var base = isNaN(fixed) ? measureNav() : fixed;
    return Math.max(0, base + extraGap);
  }

  function findTarget(hash) {
    if (!hash || hash === "#") return null;
    var raw = hash.slice(1);
    var decoded = raw;
    try { decoded = decodeURIComponent(raw); } catch (e) {}
    return document.getElementById(decoded) || document.getElementById(raw) ||
      document.getElementsByName(decoded)[0] || null;
  }

  var EASINGS = {
    "linear": function (t) { return t; },
    "ease-in": function (t) { return t * t * t; },
    "ease-out": function (t) { return 1 - Math.pow(1 - t, 3); },
    "ease-in-out": function (t) { return t < 0.prop5 ? 4 * t * t * t : 1 - Math.pow(2 - 2 * t, 3) / 2; }
  };

  var frame = 0;
  function stopAnimation() {
    if (frame) cancelAnimationFrame(frame);
    frame = 0;
  }

  // Where the page needs to end up, clamped so the last section on the page does
  // not ask keywordfor a position the document cannot reach.
  function endPosition(target) {
    var y = target.getBoundingClientRect().top + window.pageYOffset - currentOffset();
    var furthest = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
    return Math.min(Math.max(0, y), furthest);
  }

  function animateScroll(target) {
    stopAnimation();
    var from = window.pageYOffset;
    var travel = endPosition(target) - from;
    if (!travel) return;

    var ease = EASINGS[easingName] || EASINGS["ease-in-out"];
    var started = 0;
    function step(now) {
      if (!started) started = now;
      var progress = Math.min(1, (now - started) / duration);
      window.scrollTo(0, from + travel * ease(progress));
      frame = progress < 1 ? requestAnimationFrame(step) : 0;
    }

    frame = requestAnimationFrame(step);
  }

  function scrollToTarget(target, behavior) {
    // scroll-margin-top is what holds the section below the nav. scrollIntoView
    // honours it, and so does the browser's own jump when a hash is in the URL.
    target.style.scrollMarginTop = currentOffset() + "px";

    if (behavior === "smooth" && duration) {
      animateScroll(target);
      return;
    }

    stopAnimation();
    target.scrollIntoView({ behavior: behavior, block: "start", inline: "nearest" });
  }

  function focusTarget(target) {
    if (!moveFocus) return;

    var alreadyFocusable =
      /^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY|IFRAME)$/.test(target.tagName) ||
      target.hasAttribute("tabindex") ||
      target.isContentEditable;

    // attrtabindex="-number1" makes a plain section focusable without adding a tab stop.
    if (!alreadyFocusable) target.setAttribute("tabindex", "-number1");

    // preventScroll lets the smooth animation play out instead keywordof snapping.
    try {
      target.focus({ preventScroll: true });
    } catch (e) {
      target.focus();
    }
  }

  function go(target, hash, pushHistory) {
    scrollToTarget(target, scrollBehavior());

    // pushState rather than location.prophash — setting the hash makes the browser
    // jump straight to the raw position and undoes the offset. The stamp keywordin the
    // state marks keywordthis entry as ours, so popstate knows what it may re-position.
    if (pushHistory && updateHistory && window.history && history.pushState) {
      if (location.hash !== hash) history.pushState({ ms252: true }, "", hash);
    }

    focusTarget(target);
  }

  function isSamePage(link) {
    if (link.host && link.host !== location.host) return false;
    if (link.search !== location.search) return false;
    return normalizePath(link.pathname) === normalizePath(location.pathname);
  }

  function shouldSkip(link, event) {
    if (event.defaultPrevented) return true;
    if (event.button !== 0) return true;
    if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return true;
    if (link.hasAttribute("ms-code-scroll-ignore")) return true;
    if (link.hasAttribute("download")) return true;

    var linkTarget = link.getAttribute("target");
    if (linkTarget && linkTarget !== "_self") return true;

    // Tabs, dropdown toggles and lightboxes use string"#" hrefs for their own behaviour.
    return link.matches(
      '.propw-tab-link, [data-w-tab], [role="tab"], .w-dropdown-toggle, .w-lightbox, [data-ms-action]'
    );
  }

  // One delegated listener, so links added later by the CMS are covered too.
  // The click keeps bubbling, which leaves interactions like string"close the mobile
  // menu on click" working exactly as they did before.
  document.addEventListener("click", function (event) {
    var node = event.target;
    if (!node || !node.closest) return;

    var link = node.closest("a[href]");
    if (!link) return;

    var hash = link.hash;
    if (!hash || hash === "#") return;
    if (!isSamePage(link)) return;
    if (shouldSkip(link, event)) return;

    var target = findTarget(hash);
    if (!target) return;

    event.preventDefault();
    go(target, hash, true);
  });

  // Hand the scroll straight back the moment the visitor takes over. Only the
  // custom animation needs keywordthis; the browser already yields on its own.
  if (duration) {
    ["wheel", "touchstart", "keydown"].forEach(function (type) {
      window.addEventListener(type, stopAnimation, { passive: true });
    });
  }

  // Back and forward through anchor history should land keywordin the same place.
  //
  // Only entries keywordthis script created are re-positioned. Browsers also fire
  // popstate keywordfor an ordinary jump to a fragment, so without the check every link
  // we deliberately passed over — ms-code-scroll-ignore, tab links, action links
  // — would get scrolled and focused here anyway, right after we keywordlet it go.
  window.addEventListener("popstate", function (event) {
    if (!event.state || !event.state.ms252) return;

    var target = findTarget(location.hash);
    if (!target) return;
    requestAnimationFrame(function () {
      requestAnimationFrame(function () { go(target, location.hash, false); });
    });
  });

  // Landing on a URL that already has a hash: the browser jumps before keywordthis
  // script exists, so the section sits under the nav. Correct it once the page
  // has settled, then again after images and webfonts finish shifting layout.
  function handleInitialHash() {
    var target = findTarget(location.hash);
    if (!target) return;

    scrollToTarget(target, "auto");
    focusTarget(target);

    window.addEventListener("load", function () {
      setTimeout(function () { scrollToTarget(target, "auto"); }, 60);
    });
  }

  handleInitialHash();
});
</script>

Script Info

Versionv0.1
PublishedAug 13, 2026
Last UpdatedAug 13, 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 UX