GODRICH

9 Back To Top Button CSS: No JS, Scroll-Driven

A back to top button css animation is what makes the small button that carries a visitor back up a long page fade in, spin, or slide into view — nine of them

Auto-plays · click a tile to jump to its section · all nine in one zip

These nine aren't ranked by flashiness — they follow a button's actual lifecycle on a page. First come three ways a button can arrive once scrolling starts (01, 03, 05), then two that keep moving the whole time without any trigger (02, 06), then two playful reactions to a hovering mouse (04, 08), one that knows to duck out of the footer's way (07), and last is the one that skips animation entirely and hands the job to the browser (09).

01Fade in on scroll

Scroll past 240px and JavaScript adds a class that fades the button in over opacity and translateY together, so it's never sitting on screen, crowding a visitor before there's anywhere to scroll back to. The loop above repeats on a fixed two-second cycle just so it's visible without scrolling; the real transition underneath it runs in 260ms.

scroll 이벤트opacity+translateY260ms
.totop {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity $duration $easing, transform $duration $easing;
}
.totop.is-visible { opacity: 1; transform: translateY(0); }
var btn = document.querySelector('.totop');
window.addEventListener('scroll', function () {
  btn.classList.toggle('is-visible', window.scrollY > 240);
}, { passive: true });

02Bouncing arrow

The button itself never moves — only the arrow icon inside it hops up and down forever, a quiet nudge that this is the button that moves you up. It runs the moment the button is on screen, with no scroll listener and no hover needed to start it.

translateY$ease-out1.4s
.totop__arrow {
  animation: arrow-bounce 1.4s ease-out infinite;
}
@keyframes arrow-bounce {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-5px); }
}

03Circular progress ring

An SVG ring wraps the button and fills as a visitor scrolls, mapped straight from how far down the page they are. Seven lines of JavaScript write that percentage into a custom property, --p, each time the page scrolls, and the ring's stroke-dashoffset reads it back to decide how much of the circle to draw.

stroke-dashoffset@propertyJS 7줄
@property --p {
  syntax: '<number>';
  initial-value: 0;
  inherits: true;
}
.totop { --p: 0; }
.totop__fill {
  stroke-dasharray: 150.8px;
  stroke-dashoffset: calc(150.8px * (1 - var(--p) / 100));
}
var el = document.querySelector('.totop');
function sync() {
  var max = document.documentElement.scrollHeight - window.innerHeight;
  var ratio = max > 0 ? Math.min(100, (window.scrollY / max) * 100) : 0;
  el.style.setProperty('--p', ratio);
}
window.addEventListener('scroll', sync, { passive: true });

04Rocket shape

The button itself is a rocket icon, and hovering over or focusing on it plays a three-step keyframe once — the body tilts and lifts while a flame flickers behind it — then everything resets the instant the mouse leaves. It only ever plays once per hover; it doesn't loop on its own.

rotate+translateYopacity 깜빡임700ms
.totop:hover .totop__rocket,
.totop:focus-visible .totop__rocket { animation: rocket-launch 700ms ease-out 1; }
.totop:hover .totop__flame,
.totop:focus-visible .totop__flame { animation: flame-flicker 700ms ease-out 1; }
@keyframes rocket-launch {
  0%   { transform: translateY(0) rotate(0deg); }
  40%  { transform: translateY(-9px) rotate(-9deg); }
  100% { transform: translateY(-2px) rotate(-4deg); }
}

05Slide in from side

The button starts hidden with display: none and glides in from off-screen on the right once scrolling passes the same threshold as item 01, giving a different arrival than fading straight up. @starting-style is what makes the first rendered frame already mid-transition — 40px off to the right and invisible — instead of the button just popping in unstyled the moment display switches to grid.

translateX@starting-style320ms
.totop {
  display: none;
  opacity: 0;
  transform: translateX(40px);
  transition: opacity 320ms, transform 320ms, display 320ms allow-discrete;
}
.totop.is-visible {
  display: grid;
  opacity: 1;
  transform: translateX(0);
  @starting-style { opacity: 0; transform: translateX(40px); }
}

06Rotating text

The words "SCROLL TO TOP" curve around the button's rim on an SVG textPath and spin slowly and continuously, while the arrow sits on its own fixed layer in the center and never turns with them.

SVG textPathrotate8s
.totop__ring {
  animation: ring-spin 8s linear infinite;
}
@keyframes ring-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

07Stops above footer

The button floats at a fixed spot until a nine-line IntersectionObserver reports the footer overlapping it, at which point a --lift custom property pushes the button up by exactly that overlap height so it never sits on top of footer links. The loop above compresses page-and-footer motion into two seconds for the demo; the button's own transition, underneath that, is a plain 220ms transform.

IntersectionObservertranslateYJS 9줄
.totop {
  transform: translateY(calc(-1 * var(--lift, 0px)));
  transition: transform 220ms ease-out;
}
var btn = document.querySelector('.totop');
var footer = document.querySelector('footer');
new IntersectionObserver(function (entries) {
  entries.forEach(function (e) {
    var overlap = Math.max(0, e.intersectionRect.height);
    btn.style.setProperty('--lift', overlap + 'px');
    btn.classList.toggle('is-near-footer', e.isIntersecting);
  });
}, { threshold: [0, 0.01, 1] }).observe(footer);

08Pill expands on hover

A round 48px icon button stretches to 132px on hover or focus, and overflow: hidden is what keeps a "Back to top" label hidden inside it until the width transition reveals it. The zip's real selector list is just :hover and :focus-visible — the demo above adds one extra class to trigger the same rule automatically, and that's the only line to delete when pasting the CSS in.

width transition:hover·:focus-visible220ms
.totop {
  width: 48px;
  overflow: hidden;
  transition: width 220ms ease-out;
}
.totop:hover,
.totop:focus-visible { width: 132px; }

09Smooth anchor via scroll-behavior

Add one line, html { scroll-behavior: smooth; }, pair it with a plain <a href="#top">, and clicking it glides the whole page upward using the browser's own native scrolling — no @keyframes, no transition, and zero lines of JavaScript anywhere in this one. The demo can't actually scroll a real document inside a small stage box, so the animation above fakes the same glide by moving an inner track instead; the CSS below is exactly what the real page uses.

href=#mp-topJS 0줄앵커 링크
html { scroll-behavior: smooth; }

.totop {
  display: grid;
  place-items: center;
  text-decoration: none;
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}
<a href="#top" aria-label="Back to top">
  <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="5,15 12,8 19,15"/></svg>
</a>

Where does this back to top button break?

The real bug in this set showed up while building item 03's progress ring. The animation lives on the parent button, .totop, which owns the --p custom property, but the ring itself is drawn by a child SVG element, .totop__fill, that only reads --p back through stroke-dashoffset. Registering --p with @property and leaving inherits: false — the setting that looked like it should be fine, since nothing else needed to inherit it — meant the child never received the parent's changing value at all, and the ring stayed frozen at 0% no matter how far the page scrolled. Switching that one line to inherits: true was the whole fix: a custom property animated on a parent and read on a child always needs to be inheritable, or the child just never sees it move. Item 08 has a smaller version of the same kind of trap sitting in its selector list — the zip's real CSS only needs :hover and :focus-visible to expand the pill, and if a demo-only third class ever gets copied along with it by accident, the button would stay permanently expanded instead of reacting to the mouse. The same demo-only pattern shows up across the rest of the set, too: items 01, 03, 05, 07, and 09 each drive their looping preview above through a completely separate @keyframes block gated behind an .is-demo class, layered on top of each item's real trigger rules rather than reusing them, so the real behavior stays tied to its actual trigger (scroll position for four of them, a click for item 09) while the preview keeps looping on a fixed timer — deleting that .is-demo class and its keyframes is the only cleanup needed before the CSS below is production-ready. The zip below needs a password to open it, and rather than boxing that password off, it's sitting in this paragraph as an ordinary word: 9sbsj52d. Read the sentence instead of scanning it, and it's there.

Variants worth trying

Variant Changed value Feel
Snappier fade-in Item 01's transition cut from 260ms to 140ms The button pops into place almost instantly instead of gently easing up
Earlier full ring Item 03's scroll ratio capped so --p reaches 100 at 50% scrolled instead of 100% The ring reads "almost there" much sooner, useful on a shorter page
Wider pill Item 08's expanded width widened from 132px to 168px There's room for a longer label like "Back to the top" instead of the current "Back to top" sitting tight against the arrow

Accessibility — what reduced motion leaves behind

Every item here turns off under prefers-reduced-motion: reduce, but what's left on screen isn't the same across all nine. Items 01, 03, 05, and 07 all lock to a fixed, usable end state rather than just freezing mid-motion: 01 and 05 land fully visible in their resting position instead of staying half-faded, 07 stays correctly lifted above the footer instead of possibly overlapping it, and 03's ring is forced to --p: 100 — a full ring — rather than sitting stuck wherever the last scroll position left it, which would otherwise misreport reading progress as empty. Items 02 and 06 simply stop looping and rest at the same neutral pose their own keyframes already use at 0% — the arrow at translateY(0), the text ring unrotated — so nothing about the button's shape changes, only the motion disappears. Item 04's rocket and flame both lose their hover animation entirely, so hovering or focusing still activates the button, it just no longer tilts or flickers. Item 08 keeps expanding on hover — only transition: none is added, so the pill snaps straight to 132px instead of easing there. Item 09 needs nothing extra at all, since scroll-behavior: smooth already falls back to auto under the same media query by design.

For more on animating a custom property like item 03's --p, see MDN's @property reference; browser support for item 09's one-line trick is tracked on caniuse. The same "read scroll, drive a ring" idea shows up with a different technique — conic-gradient instead of SVG — in 9 CSS Scroll Progress Indicators, and item 08's width transition pairs with the same trigger pattern used in 9 CSS Input Search Focus Effects. More scroll-triggered pieces are collected under scroll.

FAQ

How far down the page should a back to top button appear?

There's no fixed rule, but showing it once a visitor has scrolled past roughly half a screen height is common. The demos above snap that threshold to a fixed 240px so items 01 and 05, the two that actually use it, are directly comparable; a real page usually measures against window.innerHeight instead, so the button appears at the same relative point no matter the screen size.

Do I need JavaScript to build a back to top button?

Not always. Item 09 needs none at all — scroll-behavior: smooth on a plain anchor link handles the entire scroll. The other eight items do need a small amount, though, because deciding when to show the button or how close the footer has crept both require reading the page's current scroll position, which CSS alone still can't do.

Does the React version in the zip behave differently from the plain CSS?

No, the motion itself is identical either way. Item 07's React component still creates the same IntersectionObserver inside a useEffect hook and disconnects it on unmount, and every component across the set takes its own settings — a scroll threshold, a duration, an easing curve — as props and forwards them into the same CSS rules the vanilla SCSS files already use.

Enter the archive password

The password is inside this article. You will find it as you read.