jQuery / Plugin / jQuery Smooth Scroll

jQuery Smooth Scroll animates navigation to a location on the same page, such as href="#beta". Select the links and call .smoothScroll(); speed controls duration and offset adjusts the destination. Include destination focus and reduced-motion handling so navigation remains useful beyond its visual effect.

Scope and dependencies

This article uses kswedberg’s jquery-smooth-scroll, not a similarly named mouse-wheel library. A link fragment must match a unique destination ID. href="#beta" links to id="beta" in the same document.

The complete example pins the CDN distribution of plugin 2.2.0 and full jQuery 4.0.0. Load jQuery, then the plugin, then initialization; slim omits the needed animation features. The same plugin was tested with jQuery 4.0.0 and 3.7.1 in Windows Edge. This does not establish compatibility with every theme or additional plugin.

Only the two menu links with .js-scroll are enhanced. Native jump and the link inside the content keep normal navigation, providing a direct comparison. Avoid applying behavior indiscriminately to every anchor on a site.

Complete example with offset and focus

Save as UTF-8 smooth-scroll-demo.html and open it. No server or build step is required; an internet connection loads the two scripts. Tall sections and extra space after the last heading make alignment observable.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Same-page smooth scrolling</title>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-smooth-scroll/2.2.0/jquery.smooth-scroll.min.js"></script>
  <style>
    :root { --anchor-gap: 100px; }
    html { scroll-behavior: auto; }
    body { font: 16px/1.6 sans-serif; margin: 20px; padding-top: var(--anchor-gap); }
    #menu { position: fixed; top: 0; left: 0; right: 0; padding: 12px; background: #e6f4f1; z-index: 10; }
    #menu a, #menu button { display: inline-block; margin: 4px 8px; font: inherit; }
    section { min-height: 700px; max-width: 700px; }
    h2 { scroll-margin-top: var(--anchor-gap); }
    h2:focus { outline: 3px solid teal; }
    footer { min-height: 100vh; }
  </style>
</head>
<body>
  <nav id="menu" aria-label="Sections">
    <a class="js-scroll" href="#alpha">Alpha</a>
    <a class="js-scroll" href="#beta">Beta</a>
    <a href="#native">Native jump</a>
    <button type="button" id="reset">Reset</button>
    <span id="status" role="status">Native links available.</span>
  </nav>
  <h1>Explore the sections</h1>
  <section><h2 id="alpha" tabindex="-1">Alpha</h2><p>First destination. Text remains available without JavaScript.</p></section>
  <section><h2 id="beta" tabindex="-1">Beta</h2><p>Second destination. Tab can continue to <a href="#native">the next destination</a>.</p></section>
  <section><h2 id="native" tabindex="-1">Native destination</h2><p>This menu link is not enhanced by the plugin.</p></section>
  <footer>Enough space to align the last heading below the menu.</footer>
  <script>
    jQuery(function ($) {
      if (typeof $.fn.smoothScroll !== "function") return;
      const $menu = $("#menu"), $status = $("#status"), duration = 600;
      function gap() {
        const value = $menu.outerHeight() + 12;
        document.documentElement.style.setProperty("--anchor-gap", value + "px");
        return value;
      }
      gap(); $(window).on("resize.demo", gap);
      $("#menu a.js-scroll").smoothScroll({
        speed: duration, easing: "swing", autoFocus: true,
        beforeScroll: function (options) {
          options.offset = -gap();
          options.speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : duration;
          $status.text("Moving to " + options.scrollTarget);
        },
        afterScroll: function (options) {
          history.replaceState(null, "", options.scrollTarget);
          $status.text("Arrived at " + options.scrollTarget);
        }
      });
      $("#reset").on("click", function () {
        $("html, body").stop(true, false); window.scrollTo(0, 0);
        history.replaceState(null, "", location.pathname + location.search);
        $status.text("Ready; enhanced links: 2.");
      });
      $status.text("Ready; enhanced links: 2.");
    });
  </script>
</body>
</html>

After initialization, the menu shows this status. With JavaScript disabled, Native links available. remains and the fragment links still work.

Ready; enhanced links: 2.
  1. Select Beta. Over about 600ms the page moves, then reports Arrived at #beta. The heading stops 12px below the fixed menu.
  2. Reset returns to the top and removes the fragment. Focus Alpha or Beta with the keyboard and press Enter.
  3. The destination gets a visible focus outline. After arriving at Beta, Tab continues to the link below it.
  4. Native jump bypasses the plugin and does not update its arrival status.
  5. Enable reduced motion and use Alpha or Beta. Duration becomes zero, while destination and completion handling remain the same.
  6. Resize the window so the menu can wrap; check that the destination remains visible. Reset and repeat under the same conditions.

tabindex="-1" allows programmatic heading focus without adding headings to the normal Tab order. autoFocus=true moves focus after scrolling instead of leaving keyboard navigation in the menu.

Duration, offset, and URL policy

speed is a duration in milliseconds here. A lower value covers the same distance faster. Change the duration constant from 600 to 1200 for one destination, then restore 600. To compare easing, keep duration fixed and change swing to linear. Other easing names require a suitable extension.

offset is added to the target coordinate. A negative value leaves space above the heading; this example subtracts actual menu height plus 12px. beforeScroll measures again so wrapping and resizing are reflected. A positive offset moves farther down and can hide the title behind the menu.

scroll-margin-top handles native links, while the plugin uses its numeric offset. Do not assume a CSS margin changes the plugin’s coordinate calculation. Check both routes.

The plugin normally prevents the default link action. This example uses history.replaceState() after completion to update the fragment. It replaces the current history entry, so each destination does not add a Back step. No hashchange navigation is implemented. Applications needing Back/Forward behavior require a separate history policy.

Reset stops effects and pending queue entries, restores scroll position, clears the fragment, and resets status. Rapid destination changes were also tested. This is not an implementation of cancellation for every wheel or user input.

CSS alternative and troubleshooting

For straightforward anchors, scroll-behavior may be enough. Use the CSS below as an alternative, not alongside the plugin. Remove both external scripts and the initialization script, plus the Reset button and status display. Replace the existing html scroll-behavior:auto rule. Keep heading scroll-margin-top and set its gap to the actual fixed-menu height.

html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

This uses browser-native fragment and history behavior, without the plugin’s millisecond duration and callbacks. The media query disables animation for reduced motion. Conversely, the plugin demo sets CSS behavior to auto so two scrolling systems do not compete.

For smoothScroll is not a function, inspect Network responses and script order. A second jQuery loaded after the plugin can discard its extension. In WordPress, inspect the theme’s registered jQuery dependencies first.

For no movement, check missing or duplicate IDs, actual href values, and selected links. For wrong alignment, check header size, images or fonts changing layout after loading, and remaining space near the document end. Scrolling an inner container requires separate scrollElement configuration and tests in that coordinate system.