Skip to content

Instantly share code, notes, and snippets.

@vinaypuppal
Last active March 17, 2022 19:10
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save vinaypuppal/b7271ad84a0d69c9cfafaaa83afed199 to your computer and use it in GitHub Desktop.
Save vinaypuppal/b7271ad84a0d69c9cfafaaa83afed199 to your computer and use it in GitHub Desktop.
Next.js smooth scroll
import React, { Children } from 'react'
import Router from 'next/router'
import smoothScroll from '../utils/smoothScroll'
// this HOC is taken from https://github.com/zeit/next.js/blob/master/lib/link.js and modified
export default class LinkSmoothScroll extends React.Component {
constructor (props) {
super(props)
this.linkClicked = this.linkClicked.bind(this)
}
linkClicked (e) {
e.preventDefault()
Router
.push(this.props.href)
.then(() => {
return smoothScroll(this.props.href)
})
.then(() => {
this.props.done && this.props.done()
})
.catch(err => {
this.props.onError && this.props.onError(err)
console.error(err)
})
}
render () {
let { children } = this.props
if (typeof children === 'string') {
children = <a>{children}</a>
}
const child = Children.only(children)
const props = { onClick: this.linkClicked }
if (child.type === 'a' && !('href' in child.props)) {
props.href = this.props.href
}
return React.cloneElement(child, props)
}
}
// Get the top position of an element in the document
const getTop = function (element, start) {
// return value of html.getBoundingClientRect().top ... IE : 0, other browsers : -pageYOffset
if (element.nodeName === 'HTML') return -start
return element.getBoundingClientRect().top + start
}
// ease in out function thanks to:
// http://blog.greweb.fr/2012/02/bezier-curve-based-easing-functions-from-concept-to-implementation/
const easeInOutCubic = function (t) {
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
}
// calculate the scroll position we should be in
// given the start and end point of the scroll
// the time elapsed from the beginning of the scroll
// and the total duration of the scroll (default 500ms)
const position = function (start, end, elapsed, duration) {
if (elapsed > duration) return end
return start +
(end - start) *
easeInOutCubic(
elapsed / duration
) // <-- you can change the easing funtion there
// return start + (end - start) * (elapsed / duration); // <-- this would give a linear scroll
}
// we use requestAnimationFrame to be called by the browser before every repaint
// if the first argument is an element then scroll to the top of this element
// if the first argument is numeric then scroll to this location
// if the callback exist, it is called when the scrolling is finished
// if context is set then scroll that element, else scroll window
const smoothScroll = function (el, duration, callback, context) {
duration = duration || 500
context = context || window
const start = context.scrollTop || window.pageYOffset
let end
if (typeof el === 'number') {
end = parseInt(el) - 60
} else {
end = getTop(el, start) - 60
}
const clock = Date.now()
const requestAnimationFrame = window.requestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.webkitRequestAnimationFrame ||
function (fn) {
window.setTimeout(fn, 15)
}
const step = function () {
const elapsed = Date.now() - clock
if (context !== window) {
context.scrollTop = position(start, end, elapsed, duration)
} else {
window.scroll(0, position(start, end, elapsed, duration))
}
if (elapsed > duration) {
if (typeof callback === 'function') {
callback(el)
}
} else {
requestAnimationFrame(step)
}
}
step()
}
export default url => {
return new Promise(function (resolve, reject) {
const pattern = /^(\/#.+)|(.+(\/#.+))$/
if (pattern.test(url)) {
const hash = pattern.exec(url).filter(item => item).pop()
const id = hash.replace(/\/?#/, '')
const el = document.getElementById(id)
if (el) {
smoothScroll(el, 600, resolve)
return
}
}
reject(new Error('Error: hash in URL is invalid or element not found!'))
})
}
@hugomallet
Copy link

hugomallet commented Jun 20, 2018

Hi,

Nice gist !

Smooth scrolling was not working as expected for me.
I think it is because Router.push calls internally scrollIntoView(), so I did this fix (a bit ugly but it works) :

// #LinkSmoothScroll.js
// ...

linkClicked (e) {
    e.preventDefault();
    const scrollX = window.pageXOffset;
    const scrollY = window.pageYOffset;

    Router
      .push(this.props.href)
      .then(() => {
         window.scrollTo(scrollX, scrollY);
         return smoothScroll(this.props.href);
// ...

@ertanhasani
Copy link

ertanhasani commented Apr 8, 2019

This is not working at all. Its just as same as using Link from next/link

It's not calling this one:

//#LinkSmoothScroll.js
  linkClicked(e) {
    e.preventDefault()
    Router
      .push(this.props.href)
      .then(() => {
        console.log('test') //this one is not being called at all when I click the <LinkSmoothScroll>
        return smoothScroll(this.props.href)
      })
      .then(() => {
        this.props.done && this.props.done()
      })
      .catch(err => {
        this.props.onError && this.props.onError(err)
        console.error(err)
      })
  }

That row is being called only when the location changes. So when I change only # in the route, that row does not gets executed.

@ertanhasani
Copy link

ertanhasani commented Apr 8, 2019

I came up with this solution, since when you are in the same page, you don't need to wait for Router.push().

inkClicked(e) {
    e.preventDefault();
    const scrollX = window.pageXOffset;
    const scrollY = window.pageYOffset;

    const location = window.location;
    const href = this.props.href;

    if (location.pathname === href.split('#')[0]) {
      Router.push(this.props.href);
      window.scrollTo(scrollX, scrollY);
      return smoothScroll(this.props.href)
    }
    else {
      Router
        .push(this.props.href)
        .then(() => {
          window.scrollTo(scrollX, scrollY);
          return smoothScroll(this.props.href)
        })
        .then(() => {
          this.props.done && this.props.done()
        })
        .catch(err => {
          this.props.onError && this.props.onError(err)
          console.error(err)
        })
    }
  }

So if you are in the same location, it will just move to that id, otherwise it will load page and then move it.

@r6m
Copy link

r6m commented Feb 1, 2021

You can also add this style to the page:

<style global jsx>
  {` html { scroll-behavior: smooth; }`}
</style>

@OlivierJM
Copy link

<style global jsx>
  {` html { scroll-behavior: smooth; }`}
</style>

This just works

@a4vg
Copy link

a4vg commented Mar 28, 2021

This is meant to work on all browsers. The scroll-behavior: smooth approach works on all major browsers except Safari.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment