How to create a scroll to top button?
Build a back to top button with a plain link and smooth scrolling, show it only after scrolling with IntersectionObserver, and keep it accessible.
The simplest version needs no JavaScript: a link to an element at the top of the page.
<body>
<header id="top" class="site-header">...</header>
<main>...</main>
<a href="#top" class="to-top">Back to top</a>
</body>Clicking the link scrolls to the element with id="top". It also helps keyboard users: the browser moves the focus starting point to that element, so the next Tab press continues from the top of the page instead of the bottom.
For smooth scrolling, add this to your CSS:
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}The media query keeps the jump instant for people who have asked their system for less motion. The rule applies to every in-page link on the site, not only this one.
Show the button only after scrolling
A floating button that's visible from the start just covers content. Show it once the user has scrolled down a screen.
Don't use a scroll listener for this. An IntersectionObserver is cheaper and simpler: watch an invisible element that covers the first screen, and show the button when it's gone.
<body>
<div class="to-top-sentinel"></div>
<header id="top" class="site-header">...</header>
...
<a href="#top" class="to-top" hidden>Back to top</a>
</body>.to-top-sentinel {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100vh;
pointer-events: none;
}const sentinel = document.querySelector('.to-top-sentinel');
const toTop = document.querySelector('.to-top');
const observer = new IntersectionObserver(([entry]) => {
// Hide the button while the first screen is still in view
toTop.hidden = entry.isIntersecting;
});
observer.observe(sentinel);The hidden attribute removes the link from view, from the tab order and from screen readers. It starts in the HTML so the button doesn't flash on page load. Without JavaScript it then never appears, which is fine for an extra like this. If you'd rather fade it in with a class, hide it with visibility: hidden as well as opacity: 0, or keyboard users can still tab to an invisible link. More options for this kind of check are in how to check if an element is visible in the viewport.
Position it in the corner
.to-top {
position: fixed;
right: calc(1rem + env(safe-area-inset-right, 0px));
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
z-index: 10;
padding: 0.75rem 1rem;
border-radius: 999px;
background: #111827;
color: #fff;
text-decoration: none;
}
.to-top:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 3px;
}env(safe-area-inset-bottom) keeps the button above the home indicator on phones without a home button. The insets are only non-zero when your viewport meta tag includes viewport-fit=cover; otherwise the fallback 0px is used.
Two more details:
- If you give
.to-topadisplayvalue, such asdisplay: flexto center an icon, it overrideshidden. Add.to-top[hidden] { display: none; }. - If the button shows only an arrow icon, give it an accessible name with
aria-label="Back to top".
The JavaScript version
The link adds #top to the URL. If you don't want that, use a button and window.scrollTo():
<button type="button" class="to-top" hidden>Back to top</button>const button = document.querySelector('.to-top');
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
button.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: reduceMotion.matches ? 'auto' : 'smooth',
});
// Without this, keyboard focus stays at the bottom of the page
document.querySelector('#top').focus({ preventScroll: true });
});Don't count on the browser respecting the reduced motion setting for behavior: 'smooth', so check it yourself. For the focus() call to work, the target needs to be focusable: add tabindex="-1" to the header. preventScroll stops focus() from jumping to the element instantly and cutting the smooth scroll short.