Nuxt.js Smooth Scrolling with Hash Links

As of Nuxt.js release 1.4.2, the default scroll behavior does not work as expected when using element ID's as hash links in routes (example: about-us/#john).

For reference: Nuxt.js Default Scroll Behavior

When navigated to directly, meaning the user immediately enters through the hash appended route, the browser handles the scroll targeting to the element with the matching ID. This is the expected behavior and works perfectly on initial page loads for completely static pages.

Once the site has loaded, however, the site operates as a single page application (SPA) and the browser stops responding to route changes as those are now handled by the vue-router. This allows for quicker page loads and navigation within the site is more controllable, but the browser no longer handles scrolling to focus on element IDs specified in hash appended routes, which has potential for breaking sites utilizing this functionality.

The solution is to override the default router.scrollBehavior method from within the nuxt.config.js configuration object.

module.exports = {
  /*
  ** Router configuration
  */
  router: {
    scrollBehavior: async (to, from, savedPosition) => {
      if (savedPosition) {
        return savedPosition
      }

      const findEl = async (hash, x) => {
        return document.querySelector(hash) ||
          new Promise((resolve, reject) => {
            if (x > 50) {
              return resolve()
            }
            setTimeout(() => { resolve(findEl(hash, ++x || 1)) }, 100)
          })
      }

      if (to.hash) {
        let el = await findEl(to.hash)
        if ('scrollBehavior' in document.documentElement.style) {
          return window.scrollTo({ top: el.offsetTop, behavior: 'smooth' })
        } else {
          return window.scrollTo(0, el.offsetTop)
        }
      }

      return { x: 0, y: 0 }
    }
  }
}

This configuration override solves two problems. First, it applies smooth to the window.scrollTo action to allow the browser to handle smooth scrolling to the proper element if available.

window.scrollTo({ top: el.offsetTop, behavior: 'smooth' })

Second, it checks for the existence of the element several times (50 to be exact) over the course of several seconds. The default scroll behavior expects the content to be loaded by the time the scroll action is called, but default Nuxt sites load the framework and start initial render before the full content is loaded from the server or CMS. The default script will give up after the first miss, causing the page to stay focused at the top. Rather than giving up after the first failed attempt, this script continues to search the DOM for the expected element every 100 milliseconds for 5 seconds (approximately). There is in theory more programmatic ways to determine when the content has finished loading, but the cost of complexity likely outweighs the fringe cases this code does not cover.

const findEl = async (hash, x) => {
return document.querySelector(hash) ||
  new Promise((resolve, reject) => {
	if (x > 50) {
	  return resolve()
	}
	setTimeout(() => { resolve(findEl(hash, ++x || 1)) }, 100)
  })
}

This approach works well in both Nuxt modes and gives a uniform user experience regardless of whether the SPA has finished loading or not.