A “Back to Top” button saves your readers from endless manual scrolling. You will build a compact, accessible, and smooth-scrolling button that only appears after the user has moved down the page. The component will be keyboard-friendly, screen-reader-friendly, and easy to drop into any project. By the end of this tutorial you will have a polished micro-interaction that fits right into your design system.
Why Back to Top Matters
Long pages convert, but they can feel heavy without navigation aids. A well-placed Back to Top control gives users a quick escape hatch and improves perceived speed. Instead of shipping an entire utility library, you can deliver a tiny, framework-agnostic button with first-class accessibility and progressive enhancement. You control the design, the motion, and the behavior with a few lines of CSS and a lightweight script.
Prerequisites
You only need basic HTML and CSS comfort. We will use custom properties for quick theming and a simple JavaScript snippet to toggle visibility when the header scrolls out of view.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
Start with a clear document skeleton. We add a landmark at the top for the link target, a content wrapper with enough length to scroll, a small sentinel element to observe scrolling, and the Back to Top control as an anchor. The anchor preserves native link behavior and works even if JavaScript is blocked.
<!-- HTML -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Back to Top Demo</title>
</head>
<body>
<header id="top" class="site-header">
<h1>Article Title</h1>
<p>Intro copy that sets context for a long piece of content.</p>
</header>
<!-- Sentinel that becomes hidden as you scroll. We will observe this. -->
<div id="top-sentinel" aria-hidden="true"></div>
<main class="content">
<section>
<h2>Section 1</h2>
<p>Lots of content...</p>
<p>More content to make the page scroll...</p>
<p>Repeat as needed to simulate a real article or docs page.</p>
</section>
<section>
<h2>Section 2</h2>
<p>Even more content to guarantee the control becomes useful.</p>
<p>Write paragraphs, add images, or code samples here.</p>
<p>Keep going so you can test visibility logic.</p>
</section>
<section>
<h2>Section 3</h2>
<p>Additional paragraphs for realistic page length.</p>
<p>Scroll, click the button, confirm it returns you instantly.</p>
<p>Check hover, focus, and motion preferences too.</p>
</section>
</main>
<a href="#top" class="back-to-top" aria-label="Back to top" aria-hidden="true">
<span class="back-to-top__icon" aria-hidden="true"></span>
</a>
<script>
// JS will be added in Step 4
</script>
</body>
</html>
Step 2: The Basic CSS & Styling
Lay down a few sensible defaults: color variables for quick theming, smooth scrolling for anchor jumps, and typography spacing for readable content. We also add the base back-to-top shape: a circular container anchored to the bottom-right corner and hidden initially.
/* CSS */
:root {
--bg: #0f172a;
--surface: #111827;
--text: #e5e7eb;
--muted: #9ca3af;
--brand: #22d3ee;
--btn-bg: #1f2937;
--btn-fg: #f9fafb;
--btn-shadow: 0 8px 24px rgba(0,0,0,0.35);
--radius: 999px;
--size: 48px;
--z-fab: 1000;
--easing: cubic-bezier(.2,.8,.2,1);
--transition: 200ms var(--easing);
}
*,
*::before,
*::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
color: var(--text);
background: radial-gradient(1200px 600px at 70% -10%, #1f2937 0%, #0b1220 45%, var(--bg) 75%);
}
.site-header {
padding: 4rem 1rem 2rem;
max-width: 70ch;
margin: 0 auto;
}
.content {
max-width: 70ch;
padding: 0 1rem 8rem;
margin: 0 auto;
}
#top-sentinel {
height: 1px;
width: 1px;
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
.back-to-top {
position: fixed;
bottom: 24px;
right: 24px;
width: var(--size);
height: var(--size);
display: grid;
place-items: center;
border-radius: var(--radius); /* circular fab */
background: var(--btn-bg);
color: var(--btn-fg);
text-decoration: none;
box-shadow: var(--btn-shadow);
transform: translateY(12px) scale(.96);
opacity: 0;
pointer-events: none;
transition:
opacity var(--transition),
transform var(--transition),
background-color var(--transition),
box-shadow var(--transition);
z-index: var(--z-fab);
outline: none;
border: 1px solid rgba(255,255,255,.08);
}
.back-to-top:focus-visible {
box-shadow:
0 0 0 4px rgba(34, 211, 238, .35),
var(--btn-shadow);
}
.back-to-top:hover {
background: #243244;
}
.back-to-top__icon {
position: relative;
display: inline-block;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid var(--btn-fg); /* triangle pointing up */
}
/* visible state toggled via JS */
.back-to-top[data-visible="true"] {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
Advanced Tip: The button uses a perfect circle via border-radius: 999px. If you want a refresher on shape techniques, see how to make a circle with CSS. The icon itself is a classic border triangle; you can explore variants in how to make a triangle up with CSS.
Step 3: Building the Button
With the base styles in place, enhance the component visuals and click target. We will add a subtle ring, increase the hit area on touch screens, and tune stacking and spacing for real pages that might have chat widgets or cookie banners.
/* CSS */
@media (hover: hover) {
.back-to-top:hover .back-to-top__icon {
filter: drop-shadow(0 1px 0 rgba(255,255,255,.25));
}
}
/* Larger hit target on small screens */
@media (max-width: 480px) {
.back-to-top {
width: 56px;
height: 56px;
bottom: 16px;
right: 16px;
}
.back-to-top__icon {
border-left-width: 9px;
border-right-width: 9px;
border-bottom-width: 12px;
}
}
/* Optional safe area support on iOS */
@supports (padding: max(0px)) {
.back-to-top {
right: max(16px, env(safe-area-inset-right));
bottom: max(16px, env(safe-area-inset-bottom));
}
}
How This Works (Code Breakdown)
The button is a fixed-position element that stays anchored to the viewport. The z-index keeps it above content. The height and width match, then border-radius creates a round shape for a familiar floating action button. If you want an arrow with a different feel, a chevron often reads better on high-density screens; compare the triangle to the technique shown in how to make a chevron up with CSS.
Hover feedback is subtle by design. On pointer devices, the icon receives a small drop-shadow for depth. On small screens, the hit target grows, which improves accessibility without reflowing desktop layouts. The safe-area block prevents clipping behind device bottom bars on iOS by using env(safe-area-inset-*) values.
Step 4: Show/Hide on Scroll with JavaScript
The control should stay hidden at the very top of the page and fade in once the header scrolls away. IntersectionObserver makes this logic smooth and battery-friendly. We watch the tiny sentinel near the top; when it is no longer visible, we flag the button as visible and update aria-hidden accordingly.
// JS
(function () {
const btn = document.querySelector('.back-to-top');
const sentinel = document.getElementById('top-sentinel');
if (!btn || !sentinel) return;
const io = new IntersectionObserver((entries) => {
const topInView = entries[0].isIntersecting;
if (topInView) {
btn.setAttribute('data-visible', 'false');
btn.setAttribute('aria-hidden', 'true');
} else {
btn.setAttribute('data-visible', 'true');
btn.setAttribute('aria-hidden', 'false');
}
}, { root: null, threshold: 0 });
io.observe(sentinel);
// Optional: fallback for very old browsers
// window.addEventListener('scroll', () => {
// const shown = window.scrollY > 600;
// btn.setAttribute('data-visible', String(shown));
// btn.setAttribute('aria-hidden', String(!shown));
// }, { passive: true });
})();
How This Works (Code Breakdown)
IntersectionObserver reports when the sentinel intersects the viewport. While it intersects, the user is near the top, so the button should hide. When it falls out of view, we switch data-visible to true and flip aria-hidden to false. The CSS reacts to data-visible by animating opacity and transform for a refined reveal. The anchor’s href points to #top so clicks work even if JavaScript is disabled, and the html smooth scrolling creates a native, GPU-accelerated motion.
Advanced Techniques: Animations & Hover Effects
Micro-interactions bring the control to life. A restrained hover lift and a focus ring provide clarity. For entrance, scale and opacity transitions avoid layout thrash and keep the UI responsive. This block adds an entry keyframe for users who prefer motion, and reduces it under prefers-reduced-motion.
/* CSS */
@keyframes fab-in {
from { opacity: 0; transform: translateY(8px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.back-to-top[data-visible="true"] {
animation: fab-in 240ms var(--easing);
}
/* Subtle hover lift only when allowed */
@media (hover: hover) {
.back-to-top:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 10px 28px rgba(0,0,0,0.45);
}
}
/* Motion reduction */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.back-to-top,
.back-to-top:hover {
transition: none;
animation: none;
transform: none;
}
}
Transform-based effects are the safest choice for performance. They avoid layout recalculation and paint storms. The focus-visible ring remains active while motion is disabled, so keyboard users keep a clear indication of focus state without extra movement.
Accessibility & Performance
A tiny widget can still miss critical details. Cover these fundamentals and the control will feel native to your site.
Accessibility
The element is an anchor with an explicit aria-label so screen readers announce “Back to top.” The icon is decorative, so the inner span carries aria-hidden to remain silent. The visible state toggles aria-hidden on the control itself to keep it out of the focus order when hidden. The focus-visible rule gives a high-contrast ring that meets WCAG guidelines on dark backgrounds. The button size and spacing consider touch ergonomics, and the safe-area handling prevents accidental clipping on iOS. For motion, prefers-reduced-motion disables animations and smooth scrolling to respect user settings.
Performance
IntersectionObserver runs on a compositor-friendly path and avoids the cost of scroll polling. If you need a fallback, use a passive scroll listener and a quick threshold check. The animation uses opacity and transform only, which keeps frames smooth on low-power devices. The CSS file defines variables once and reuses them, so color and spacing tweaks never require a refactor. Since the control is an anchor, there is no script required for the click behavior, which removes a potential jank source.
Polish That Pays Off
You built a Back to Top button that looks sharp, respects user preferences, and degrades gracefully. The component stays out of the way until it helps, then it gets the user home in one click. Keep this pattern in your toolbox and shape the icon to match your brand, whether you prefer a triangle, a chevron, or an outlined circle built from the same shape techniques you already know.