Visit

Anchor links with ScrollSmootherin Nuxt

The short answer

Vue Router scrolls by measuring an element and calling window.scrollTo, and ScrollSmoother moves the page with a transform instead. To make anchors work, override scrollBehavior in app/router.options.js, call smoother.scrollTo yourself, and return false so the router does not scroll as well.

Why the default breaks

ScrollSmoother keeps the content in a fixed, overflow-hidden wrapper and positions it with a transform that lags the real scroll position. The router therefore measures a moving target, and it lands somewhere the smoother never agreed to.

The worse symptom comes afterwards. The smoother's internal offset no longer matches window.scrollY, and the top of the page can become unreachable.

Hand the scroll to the smoother

When the route stays on the same page, everything can glide. An anchor glides to its section, and anything else glides back to the top. Without a smoother, as under reduced motion, the normal return values let the browser scroll natively.

const resolvePosition = (smoother, to, savedPosition, smooth) => {
  if (savedPosition) {
    if (smoother) {
      smoother.scrollTo(savedPosition.top, false);
      return false;
    }
    return savedPosition;
  }

  if (to.hash) {
    const target = document.querySelector(to.hash);
    if (!target) return false;
    if (smoother) return scrollToTarget(smoother, target, smooth);
    return { el: to.hash, top: navbarOffset() };
  }

  if (smoother) {
    smoother.scrollTo(0, smooth);
    return false;
  }
  return { left: 0, top: 0 };
};

Measure the layout, not the screen

Passing the element itself to the smoother measures it with getBoundingClientRect. After a page change that includes the incoming page's enter transition, so the anchor lands short by that offset. Summing offsetTop reads the position before any transform, which gives the resting spot.

const layoutTop = (el) => {
  let top = 0;
  for (let node = el; node; node = node.offsetParent) top += node.offsetTop;
  return top;
};

Wait for the next page

A different page has not rendered when scrollBehavior runs. With an out-in page transition the target is not in the DOM until the old page has left, so the scroll has to wait for page:transition:finish. The very first navigation has no page to transition out of, so it waits for page:loading:end instead.

const settled = from.matched.length
  ? "page:transition:finish"
  : "page:loading:end";

return new Promise((resolve) => {
  nuxtApp.hooks.hookOnce(settled, () => {
    requestAnimationFrame(() => {
      resolve(resolvePosition(ScrollSmoother.get(), to, savedPosition, false));
    });
  });
});

Two traps that remain

NuxtLink renders a bare hash as a plain anchor that never reaches the router, so the browser jumps by itself and bypasses all of this. Write /#about rather than #about.

Find-in-page and scrollIntoView can also scroll the wrapper directly, because overflow: hidden still scrolls programmatically. Listen for scroll on the wrapper, reset its scrollTop to 0, and pass the offset back to the smoother as a real scroll position.

Let’s Stay Connected

Got questions or want to collaborate? Feel free to reach out — I’m open to new projects or just a casual chat.

0%