Page scrolling in vanilla JavaScript
I published this article years ago. At the time of writing, this solution was working very well for me. Today I would be using window.scroll
and this lightweight smooth scroll behavior polyfill instead.
How many times have you seen the effect of a page scrolling down after clicking a button? Probably thousands! It’s always been extremely easy to do with the popular jQuery library.
$('.js-btn').click(() => {
$('html, body').animate({
scrollTop: $('.js-section').offset().top
}, 200);
});
See the Pen Page scrolling in vanilla JavaScript 1 by Pawel Grzybek (@pawelgrzybek) on CodePen.
It is a decent solution, works great and it’s really well supported across the browsers. But there is a recent trend of abandoning jQuery because pure vanilla JavaScript DOM manipulation is the new hipster skill (I’m one of those hipsters by the way). With the ease of modern APIs and the amount of features that the JavaScript landscape has to offer nowadays it is not that difficult to leave chunky libraries behind.
On one recent project my client asked me to implement this kind of scrolling on his SPA (single page app). Aha! A “challenge” I said! Today I think “DOM-nightmare-inconsistency-mission” is a better term to describe this scenario. If you are one of those hipsters let me save you a couple of hours and share this tiny snippet with you.
Page scrolling without jQuery
Plan! To start a script it’s always a good idea to have a plan in place. Basically it goes like this:
- Determine where to scroll, the duration, the easing function and an optional callback.
- On click — grab a timestamp and the current document position.
- Scroll to the element as long as you don’t reach the destination.
- If the element has finished scrolling trigger an optional callback function.
Determine where to scroll, the duration, the easing function and an optional callback
All the other steps are always going to be exactly the same. This one may vary depending on the destination, the scrolling duration, the timing function and any callback that is invoked when the scrolling reaches it’s destination. It makes sense to pass all these things as function arguments, right? The destination is the only required argument (ideally it should be a number or DOM element, and function should determine how to deal with it). The duration and easing function possess some sensible default values (thanks to ES2015 default arguments) and the callback function should be optional. Have a look at the wrapper of our function declaration.
function scrollIt(destination, duration = 200, easing = 'linear', callback) {
// object with some some timing functions
// function body here
}
On click — grab a timestamp and the current document position
To calculate values for function that is responsible for scrolling window position up and down, we need to have a reference to initial window value and timestamp.
const start = window.pageYOffset;
const startTime = 'now' in window.performance ? performance.now() : new Date().getTime();
Scroll to the element as long as you don’t reach the destination
The most popular JavaScript animation solutions are mainly based on setTimeout, setInterval, the WEB Animation API and requestAnimationFrame. The first two are pretty old school. The Web Animation API isn’t made to deal with these kind of situations — read more about it in one of my previous articles. So requestAnimationFrame
looks like a perfect candidate for this scenario. We have to be careful tho — it is easy to generate infinite loop if we request a frame loop without providing condition to terminate it. One of those situation can be scrolling below available scrollable window space. Luckily it is not difficult to prevent it. In case that requestAnimationFrame
is not available we can just skip animation and move window to the destination. Have a look…
const documentHeight = Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);
const windowHeight = window.innerHeight || document.documentElement.clientHeight || document.getElementsByTagName('body')[0].clientHeight;
const destinationOffset = typeof destination === 'number' ? destination : destination.offsetTop;
const destinationOffsetToScroll = Math.round(documentHeight - destinationOffset < windowHeight ? documentHeight - windowHeight : destinationOffset);
if ('requestAnimationFrame' in window === false) {
window.scroll(0, destinationOffsetToScroll);
if (callback) {
callback();
}
return;
}
function scroll() {
const now = 'now' in window.performance ? performance.now() : new Date().getTime();
const time = Math.min(1, ((now - startTime) / duration));
const timeFunction = easings[easing](time);
window.scroll(0, Math.ceil((timeFunction * (destinationOffsetToScroll - start)) + start));
requestAnimationFrame(scroll);
}
If the element has finished scrolling trigger an optional callback function
The last step is to trigger a callback function whenever the document reaches its destination. This requires adding one more line to the condition that checks the current position and destination inside the scroll
function.
// Stop requesting animation when window reached its destination
// And run a callback function
if (window.pageYOffset === destinationOffsetToScroll) {
if (callback) {
callback();
}
return;
}
Putting it all together
The whole function looks like this.
function scrollIt(destination, duration = 200, easing = 'linear', callback) {
const easings = {
linear(t) {
return t;
},
easeInQuad(t) {
return t * t;
},
easeOutQuad(t) {
return t * (2 - t);
},
easeInOutQuad(t) {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
},
easeInCubic(t) {
return t * t * t;
},
easeOutCubic(t) {
return (--t) * t * t + 1;
},
easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
},
easeInQuart(t) {
return t * t * t * t;
},
easeOutQuart(t) {
return 1 - (--t) * t * t * t;
},
easeInOutQuart(t) {
return t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t;
},
easeInQuint(t) {
return t * t * t * t * t;
},
easeOutQuint(t) {
return 1 + (--t) * t * t * t * t;
},
easeInOutQuint(t) {
return t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t;
}
};
const start = window.pageYOffset;
const startTime = 'now' in window.performance ? performance.now() : new Date().getTime();
const documentHeight = Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);
const windowHeight = window.innerHeight || document.documentElement.clientHeight || document.getElementsByTagName('body')[0].clientHeight;
const destinationOffset = typeof destination === 'number' ? destination : destination.offsetTop;
const destinationOffsetToScroll = Math.round(documentHeight - destinationOffset < windowHeight ? documentHeight - windowHeight : destinationOffset);
if ('requestAnimationFrame' in window === false) {
window.scroll(0, destinationOffsetToScroll);
if (callback) {
callback();
}
return;
}
function scroll() {
const now = 'now' in window.performance ? performance.now() : new Date().getTime();
const time = Math.min(1, ((now - startTime) / duration));
const timeFunction = easings[easing](time);
window.scroll(0, Math.ceil((timeFunction * (destinationOffsetToScroll - start)) + start));
if (window.pageYOffset === destinationOffsetToScroll) {
if (callback) {
callback();
}
return;
}
requestAnimationFrame(scroll);
}
scroll();
}
…and to invoke it
document.querySelector('.js-btn1').addEventListener('click', () => {
scrollIt(
document.querySelector('.js-section1'),
300,
'easeOutQuad',
() => console.log(`Just finished scrolling to ${window.pageYOffset}px`)
);
});
or simply
document.querySelector('.js-btn50000').addEventListener('click', () => scrollIt(50000));
See the Pen PURE JS scrolling by Pawel Grzybek (@pawelgrzybek) on CodePen.
A future solution using scroll-behavior: smooth
UPDATE! As correctly pointed out by Šime Vidas there is another solution. There is a property of the CSSOM View module called scroll-behavior
. This is a native solution for the problem that I’m trying to solve by my script. The implementation is extremely easy, but unfortunately this method isn’t supported well enough to be used reliably (yet). It doesn’t allow us to control timing functions or the duration either. It takes the user-agent values as its defaults. If you want to test examples below, use Firefox or Google Chrome with Experimental Web Platform features flag enabled.
function scrollIt(element) {
window.scrollTo({
'behavior': 'smooth',
'left': 0,
'top': element.offsetTop
});
}
const elm = document.querySelector('.js-section');
scrollIt(elm);
See the Pen 2016.07.25 - 3 by Pawel Grzybek (@pawelgrzybek) on CodePen.
And one more example using just a CSS (Firefox only)
body {
scroll-behavior: smooth;
}
<a href="#one" class="btn">Section 1</a>
<div id="one" class="section">Section 1</div>
See the Pen 2016.07.25 - 4 by Pawel Grzybek (@pawelgrzybek) on CodePen.
Wrap it up
Please let me know what you think about my solution. I know that the browser support isn’t that amazing compared to the usual jQuery solution. The compromise between browser support, bloating code and performance is a question that you need to answer yourself depending on your project. I had good fun building this script but it’s even more enjoyable for me to share it with you.
No mention of the standard scrolling API (part of CSSOM View)?
Thank you @simevidas:disqus. I'll update article. Very good reminder. Because of limited browser support I'll be stick to my method for the time being though. Thanks again!
There’s a polyfill for the standard API https://github.com/iamdusta... 😊
I think that till it won't be implemented across major browsers it isn't a standard. History showing that 'standards' from html specs have been removed as some browser didn't implemented that. Other words saying, CSSOM is still in draft, smooth is just a proposition. Also it's 'hard' to call this an API when there is no function imho -> https://drafts.csswg.org/cs...
which not allowing ex. adding animation.
I wish that their will implement Pawel approche to CSSOM as this is great code.
The
smooth
option is supported in two browser engines, Chrome and Firefox. The fact that the CSSOM View spec is a draft isn’t relevant, since that’s the standard format in which CSS modules are developed; what matters is how many browser engines support a given feature. In this case, the support from Chrome and Firefox pretty much ensures that the other two engines will implement it eventually, and in the meantime, there’s the polyfill.First flexbox v1 was supported too by major browsers and what's happen with it ? Was scraped.
Secondly, use polyfiil if they are crucial for your design.
Thirdly, follow performance vs features :
- polyfill here is 327 lines of code
- it's more heavy then code from this article
- polyfill have differently 'smooth' on safari then in native chrome
- no option to add different ease on '~native smooth scroll'
- Chrome release smooth in december last year https://www.chromestatus.co... therefore it's too soon to be sure that there is no bugs on it.
If you prefer to use that, fair enough.
I won't use that as same as the other hundreds of polyfills and features till they won't be supported 3 version below by major browsers.
You can’t compare this to Flexbox, which was a major feature, an entire new layout model (the first modern layout model in CSS, I think).
With the standard `smooth` option, you get a feature that already works in the two most-evergreen browser engines and that, btw, works great as progressive enhancement, and whose polyfill is based on a CSS spec. It really can’t get any better than that.
Yes, the feature is basic, but that’s a good thing. Simpler features are easier to implement, which allows the web platform to move forward in small steps, instead of having a more complex feature scrapped because browsers couldn’t agree on the functionality. Smooth scrolling is valuable even in this most-basic form.
Pawel this line: (67) const time = Math.min(1, ((now - startTime) / duration)); is GENIOUS and it took me a while to understand it! (Date.now() - startTime) will be >= 1 after the duration! You didn't really elaborate on this part in your blog post so I thought I would ask: Did you came up with this timing solution or you have any source you can link so I can learn more about similar techniques? Thank you in advance for any information and again thank you for all the work you do and share! Cheers from Arizona!
Hi @jakubrpawlowski:disqus.
Thanks for your kind words. The line that you asked about is a quite common pattern especially when you work with
requestAnimationFrame()
. I cannot give you any resource because I don't know about any. Keep a simple logic and you will understand it better.Essentially
requestAnimationFrame()
always aims to do something. It aims to move things to particular place, it may do something for some period of time, it may aim to reach some position...This one liner checks it
requestAnimationFrame()
achieved it's aim. If yes — stop. Of not call it again. Simple :)Have a great day.
This is great. Thank you!!!
Paweł, your solution is exactly what I was looking for. Thank you so much! But I think that I have found a little bug that was appearing in Chrome browser (did not check other browsers). Basically I could not scroll from section 2, 3 or higher back to section 1. It was scrolling up and then jumping back to section that I was before calling scroll. I think it is because Chrome uses float value for document.body.scrollTop. I found solution for this bug and now it works perfectly. I just had to change `if (body.scrollTop === destination) ` into `if (Math.ceil(body.scrollTop) === destination)`
Pozdrowienia z Polski :)
Cześć Rafał.
This blogpost is a bit outdated and I found a much cleaner way to do it in the meantime with exactlly the same browser coverage. Have a look at this codepen...
http://codepen.io/pawelgrzy...
I will update this blogpost very soon and implement an updated version of this script here. Maybe even smash some tiny npm module :)
Pozdrowionka :)
You are seeing that probably because `document.querySelector('.js-btn50000')` only returns the *first* element. You can use `querySelectorAll('.className')`, but expect a NodeList back which you then `let...of` through to `addEventListener()`.
If the destination is less then the windowHeight from the bottom of the page, the script can't reach it and ends up in a never-ending loop trying to get to get to it, to fix this I replaced:
with:
What is the licence of this code?
Hey, great tutorial thank you for sharing. One thing the GSAP ScrollTo plugin does well is that it senses if the scroll position was changed outside of itself and cancel that portion of the tween. Any idea how you would go about doing that?
Great work! But there is a problem.
If you zoom the page in Chrome, the script will try to infinitely scroll. This is due to the fact that window.pageYOffset is not equal to destinationOffsetToScroll.
Please, fix this. THX!
Great post. I love this
Hello Pawel. Nice code and description, but one more thing I would add is :
in case there is a fixed header -
destinationOffsetToScroll should be replaced with something like
destinationOffsetToScroll - fixedHeaderOffSet.
and add default value as 0 for a fixed header height
This is really great and well written, thanks!
Thanks a ton. Happy using :-)
You save my day.. Thank you very much!!!!
My pleasure! Hope it helped you out.
It's working wrong for me. Correct top position calculation should use getBoundingClientRect as:
topPos = element.getBoundingClientRect().top + window.scrollY;
This doesn't seem to be working right for me on Windows. The function kicks off as expected, but then scroll() becomes an infinite loop running over-and-over preventing scrolling on the page as it just bounces me back. I am seeing this same issue both within my project and your codepen.
It's happening on Edge and Chrome Version 63.0.3239.108 (Official Build) (64-bit). Any guesses?
I came across this issue with this solution at some point. I'll try to dig a solution that I used to fix this issue and I'll refactor a snippet. Thanks fro reporting.
Thanks. It's odd because on my Mac I didn't experience this issue at all so I'm a bit unsure why they would be acting different even when running the same browser. Nonetheless if you do find the fix that'd be great!
Hi.
I think that I found the way to solve an issue. Try to replace this line...
with
Let me know if that helped please.
That snippet didn't work for me as `y is undefined`. That said, I messed around with this a bit and identified what was happening.
It seems that if you add an offset to `window.scroll(0, (timeFunction * (destinationOffsetToScroll - start) + start));`, you'll actually get an infinite loop for `time`.
I forked your codepen to show such an example https://codepen.io/ekfuhrma....
What ended up resolving the issue was moving the scroll offset out of the following:
```
window.scroll(0, Math.ceil((timeFunction * (destinationOffsetToScroll - start)) + start);
```
and into
```
const destinationOffsetToScroll = Math.round(documentHeight - destinationOffset < windowHeight ? documentHeight - windowHeight : destinationOffset);
```
The working line with a variable offset is as follows:
```
const destinationOffsetToScroll = Math.round(documentHeight - destinationOffset < windowHeight ? (documentHeight - windowHeight) - scrollOffset : destinationOffset - scrollOffset); // scroll offset from top
```
Apologies for taking up some of your time, and thank you for helping me work through it to get a solution.
EDIT: I'm unsure how to make code snippets here, but hopefully you can parse through it enough to make sense of what I'm trying to say.
I'm getting `y is Undefined` using that line.
That said, I poked around a bit deeper and identified the issue and solution.
I'm not entirely familiar with the time function being used in `scroll()`, but that was the main culprit with my loop.
I was adding an offset to the scroll to account for some padding I wanted, and was adding it to the following line:
In doing so, it made
become an infinite loop, breaking the scroll after using it. I threw together an example of this in a fork of your codepen.
Once I realized the culprit of the infinite loop, I was able to figure out where I could safely add an offset without breaking the whole thing.
So with that said, if you want to add an offset to the scroll, be sure to do it within the
variable. My final working variable with the offset looked like the following:
Again, apologies for the inconvenience and thank you for your time. Though that line didn't ultimately help, it did help me get on the right path to resolving the issue.
I've encountered same problem - infinite loop - after adding some pixel to scroll or browser zoom-in/out.
You saved the day! Thank you Eric, and both of you.
Please find my hotfix (maybe not perfect but works at least in my project).
---
// The fix to support scrolling up/down on any zoom (Google Chrome)
// if (window.pageYOffset === destinationOffsetToScroll) {
const stopResult = (window.pageYOffset / destinationOffsetToScroll).toFixed(2);
if (stopResult === '1.00' || stopResult === '0.99') {
if (callback) {
callback();
}
return;
}
--
thanks for sharing
Dude I just login bc of upvote that post. Thank you to sharing your solution with us
Sorry to bug you again regarding this, but was wondering whether or not you found some time to dig up that old solution you had for it?
Hi.
First of all, amazing implementation and exactly what I was looking for. A smooth scroll in 100 lines of code instead of having a jquery library as a dependency.
It works like a charm in linux and mac but on windows it is not, even tho i read all the comments and the possible solutions. First bug is that when I click on the links I cannot scroll from the mouse and the second bug i cant scroll to home(top section), in both there is an infinite loop. Same bugs happen in the code in your codepen. Here is my codepen: https://codepen.io/anon/pen...
Thanks in advance
I am aware ot that. It mainly happens when you have a document scaled up or down (via Cmd + or Cmd -). I will find a solution for this oen at some point and come back to this post and update it. I will keep you up to date about my solution progress.
Thanks
Thank you, I am waitng because i really want to use it :)
Hello Paweł,
Do you have any update on this?
I've tried to fix the problem myself but with no success.
Thanks for great post!
Nice job.
For those who want to use it on their website, you can babelify (and eventually minify) it here: https://babeljs.io/repl/
Does it correctly work for you in IE 11? I got a bug in IE 11
ie and edge, did not work at me
is there a way to add "current" class when scrolling ?
Hi.
It is not build in this script, but it is fairly easy to add this functionality. It would be something like this:
Thanks for reading!
nice
Thank you for that great piece of code.
We unfortunately experienced some issues regarding scroll targets with float numbers. In cases where the user has zoomed the page, the target can be determined as a float type, which results in a strange behavior as the site get stuck at the desired scroll target. The user can't scroll any further. Firefox can handle float numbers, but Safari start rounding up that numbers and references it internally still as a float. Thus your condition where the callback is triggered at the desired position is never triggered. Our fix was the following snippet, look for the lastScrollPosition variable. We check if the value in 'lastScrollPosition' hasn't changed and in that case we assume that we reached our desired target:
Yes. I knew about this issue. As I mentioned on a beginning of this post, I highly suggest using a native scroll API to use, instead of any other custom implementation or library. If you need a browser support, it is then consider solutions like this as a fallback option.
if (Math.abs(window.pageYOffset - destinationOffsetToScroll) < 1) {
is enough, instead of
if (lastScrollPosition === window.pageYOffset || Math.abs(lastScrollPosition === window.pageYOffsetwindow.pageYOffset - destinationOffsetToScroll) <= 1) {
Two years later: Really a very fine script, easy to use and working even on my old iPad (iOS9!). Ein herzliches Danke aus Deutschland! Ulrike
Hi @uhaessler:disqus
I am glad that you found this article useful. Personally I wouldn't use this approach anymore. Have a look at the note at the very top of this post.
Have a great day 🥑
Thanks heaps for the article!
I am glad it helped you out. Enjoy :)
Hi, nice script. I want base on it and make angular directive (NPM). MIT of course :) Can I?
Sure, but I would highly advise to read an update section on the very top of this article. There are much better techniques to achieve this effect nowadays. Enjoy 🥑
After I run this script, only in the Android, I have problem that:
- I cant scroll the page anymore.
So suggestion why?
const scrollItWithEasing = (destination, duration = 200, easing = 'linear', offset = 0, callback) => {
const easings = {
linear(t) {
return t;
},
easeInQuad(t) {
return t * t;
},
easeOutQuad(t) {
return t * (2 - t);
},
easeInOutQuad(t) {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
},
easeInCubic(t) {
return t * t * t;
},
easeOutCubic(t) {
return (--t) * t * t + 1;
},
easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
},
easeInQuart(t) {
return t * t * t * t;
},
easeOutQuart(t) {
return 1 - (--t) * t * t * t;
},
easeInOutQuart(t) {
return t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t;
},
easeInQuint(t) {
return t * t * t * t * t;
},
easeOutQuint(t) {
return 1 + (--t) * t * t * t * t;
},
easeInOutQuint(t) {
return t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t;
}
};
const start = window.pageYOffset;
const startTime = 'now' in window.performance ? performance.now() : new Date().getTime();
const defaultOffset = offset;
const documentHeight = Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);
const windowHeight = window.innerHeight || document.documentElement.clientHeight || document.getElementsByTagName('body')[0].clientHeight;
const setDestination = destination.offsetParent.offsetTop === 0 ? destination.offsetTop : destination.offsetParent.offsetTop;
const destinationOffset = typeof destination === 'number' ? destination : setDestination - defaultOffset;
const destinationOffsetToScroll = Math.round(documentHeight - destinationOffset < windowHeight ? documentHeight - windowHeight : destinationOffset);
// console.log('documentHeight', documentHeight)
// console.log('windowHeight', windowHeight)
// console.log('destinationOffset', destinationOffset)
// console.log('destinationOffsetToScroll', destinationOffsetToScroll)
if ('requestAnimationFrame' in window === false) {
window.scroll(0, destinationOffsetToScroll);
if (callback) {
callback();
}
return;
}
const scroll = () => {
const now = 'now' in window.performance ? performance.now() : new Date().getTime();
const time = Math.min(1, ((now - startTime) / duration));
const timeFunction = easings[easing](time);
const destination = Math.ceil((timeFunction * (destinationOffsetToScroll - start)) + start);
window.scroll(0, destination);
if (window.pageYOffset === destinationOffsetToScroll) {
if (callback) {
callback();
}
return;
}
requestAnimationFrame(scroll);
}
scroll();
}
export default scrollItWithEasing;
And I call it via another function with:
scrollItWithEasing(
document.getElementById(findId),
300,
'easeInOutQuad',
setOffset
);
Hello, yes probaly it is an infinite loop because the resulting scrollOffset did not 100% match the expected destinationOffset.
At best have a look at the other post where I have introduced improvements to not have that problem ;-)
Cheers!
Hello, And thank you for this article :D
Helped me alot to build my solution for this scenario - therefore I made some improvements I'd like to share
So basicly I calculate the desired destinationScrollOffset for each animationFrame.
Because sometimes I have geometry changes like collapsing menues onScroll ;-)
Next is to make the termination of the scrolling dependent on the time instead of the scrollOffset.
This gets rid of the possible infite loops.
IMO the "inaccuracy" is ok in any way because it cannot reliably be made 100% accurate. (That's why the infinite loops happen at some point.)
Cheers!
document.getElementById("scroll_to_results").scrollIntoView({
block: 'start',
behavior: 'smooth'
});
Yes! This is exactly what I suggest using nowadays.
https://pawelgrzybek.com/pa...
Great post, thanks!
Pleasure. Have a fab weekend!
Nice work