EmpireUI
Get Pro
← Blog8 min read#back to top#scroll#react

Back to Top Button in React: Scroll Progress, Smooth, Accessible

Build a fully accessible back-to-top button in React with scroll progress tracking, smooth animation, and keyboard support — from scratch in under 60 lines.

React scroll progress indicator button floating above code editor screen

Why Bother Building This From Scratch?

You'd think a back-to-top button would be a solved problem by 2026. Scroll down 300px, show a button, click it, go back up — done. But the devil is in the details, and the details are what separates a component that actually feels good from one that just works.

Most tutorials stop at window.scrollTo(0, 0). That gets you a working button, sure, but you also get zero accessibility, no visible scroll progress, a jarring snap to the top instead of a smooth scroll, and probably a useEffect that fires on every render because someone forgot the dependency array. Not ideal.

Honestly, the back-to-top button is one of those components where doing it right teaches you a surprising amount — event throttling, IntersectionObserver vs scroll listeners, ARIA attributes, CSS transforms. It's worth the 20 minutes. This guide walks through building it progressively, so you can stop at whatever level of polish you actually need.

If you're already deep in UI component work, the Empire UI library has polished interactive patterns you can drop into your project immediately. But for this one, let's build it ourselves.

The Basic Hook: Tracking Scroll Position

The first thing you need is a hook that tells you how far down the page you are. Two things matter: whether the button should be visible at all, and how much of the page the user has read (for the progress ring). React 18+ handles both cleanly.

Here's the hook. It tracks both the raw scroll position and the percentage scrolled:

import { useState, useEffect } from 'react';

function useScrollProgress() {
  const [scrollY, setScrollY] = useState(0);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    let ticking = false;

    const onScroll = () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          const y = window.scrollY;
          const totalHeight =
            document.documentElement.scrollHeight - window.innerHeight;
          setScrollY(y);
          setProgress(totalHeight > 0 ? (y / totalHeight) * 100 : 0);
          ticking = false;
        });
        ticking = true;
      }
    };

    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  return { scrollY, progress };
}

The ticking flag with requestAnimationFrame is the important bit. Without it, you're running your state updates on every single scroll event — which on a smooth display is 120 times per second. Your React tree doesn't need to re-render that fast. { passive: true } on the listener is worth adding too; it tells the browser you won't call preventDefault(), which lets it skip a check on every frame. Worth noting: this matters most on mobile where scroll jank is felt immediately.

That said, some people reach for IntersectionObserver here instead of a scroll listener. It's a valid approach if all you need is show/hide — observe a sentinel element at the top of the page, and toggle visibility when it leaves the viewport. But IntersectionObserver doesn't give you the percentage scrolled, so for a progress ring you'll want the scroll listener approach above.

Building the Button Component

With the hook in place, the component itself is straightforward. The interesting part is the progress ring — an SVG circle whose stroke-dashoffset animates as the user scrolls. This is the same technique used by reading progress bars at the top of article pages.

import { useCallback } from 'react';

const RADIUS = 20;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

export function BackToTop() {
  const { scrollY, progress } = useScrollProgress();
  const visible = scrollY > 400;

  const scrollToTop = useCallback(() => {
    window.scrollTo({ top: 0, behavior: 'smooth' });
  }, []);

  const strokeOffset = CIRCUMFERENCE - (progress / 100) * CIRCUMFERENCE;

  return (
    <button
      onClick={scrollToTop}
      aria-label="Back to top"
      aria-hidden={!visible}
      tabIndex={visible ? 0 : -1}
      className={[
        'fixed bottom-8 right-8 z-50 w-12 h-12 rounded-full',
        'bg-white/10 backdrop-blur border border-white/20',
        'flex items-center justify-center',
        'transition-all duration-300',
        visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none',
      ].join(' ')}
    >
      <svg
        width="48"
        height="48"
        viewBox="0 0 48 48"
        className="absolute inset-0 -rotate-90"
        aria-hidden="true"
      >
        <circle
          cx="24" cy="24" r={RADIUS}
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          className="text-white/10"
        />
        <circle
          cx="24" cy="24" r={RADIUS}
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeDasharray={CIRCUMFERENCE}
          strokeDashoffset={strokeOffset}
          strokeLinecap="round"
          className="text-violet-400 transition-all duration-150"
        />
      </svg>
      <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
        <path
          d="M8 12V4M4 8l4-4 4 4"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          fill="none"
        />
      </svg>
    </button>
  );
}

A few things going on here. The 400px threshold for visible is intentional — showing the button at 300px feels too eager on long pages. The -rotate-90 on the outer SVG reorients the circle so progress starts from the top rather than the right. And strokeDashoffset is the classic CSS trick: set the dash array equal to the full circumference, then adjust the offset to reveal more of the stroke.

The aria-hidden + tabIndex combo is what makes this accessible without extra work. When the button's invisible, screen readers skip it and keyboard tabbing ignores it. When it's visible, both work normally. Don't skip this — a button that can receive focus while invisible is a genuine accessibility failure. In practice, this pattern is cleaner than display: none toggling because it lets your CSS transition run properly.

Quick aside: the pointer-events-none class on the hidden state stops the button from intercepting clicks even when it's at opacity 0 during the transition. Without it, there's a brief window where a click in the bottom-right corner does nothing visible but still fires the scroll handler.

Adding Keyboard and Focus Handling

The button above handles keyboard focus correctly already via tabIndex, but there's one more thing worth thinking about — what happens when the user presses Home on the keyboard? That already scrolls to the top natively. You don't need to intercept it. But if your scroll container is a custom element (not window), you'll need to attach the scroll listener to that element instead.

For users who have prefers-reduced-motion set in their OS, behavior: 'smooth' on window.scrollTo is still somewhat respected by the browser, but not always. You should check it explicitly:

const scrollToTop = useCallback(() => {
  const prefersReduced = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;

  window.scrollTo({
    top: 0,
    behavior: prefersReduced ? 'instant' : 'smooth',
  });
}, []);

This is a two-line addition that covers a real accessibility requirement. The WCAG 2.1 Success Criterion 2.3.3 (AAA) asks for reduced motion alternatives. Even if you're not targeting AAA, it's the right thing to do — people with vestibular disorders find smooth scrolling physically uncomfortable. Look, this isn't optional polish for a production component; it's basic respect for your users.

One more thing — after the scroll completes, you might want to move focus back to the top of the document. For really long pages this is expected behavior. You can do it by setting a tabIndex={-1} on your <main> or first heading element and calling .focus() after the scroll. That said, it's a judgment call based on your page structure.

Styling Variants: Minimal, Glassmorphism, Progress Ring

The component above uses a glassmorphism-style background out of the box — bg-white/10 backdrop-blur border border-white/20. This looks great on dark pages and integrates naturally with the glassmorphism components in Empire UI. But you might want variants.

Here's a solid dark variant with a flat background, and a minimal floating variant that just shows the arrow:

// Solid dark variant
const solidDark = [
  'bg-gray-900 border border-gray-700',
  'text-white hover:bg-gray-800',
  'shadow-lg shadow-black/30',
].join(' ');

// Minimal floating
const minimal = [
  'bg-transparent border-none',
  'text-gray-500 hover:text-gray-900',
  'shadow-none',
].join(' ');

// Accent color (using CSS custom property)
const accent = [
  'bg-[var(--color-accent)] border-transparent',
  'text-white hover:opacity-90',
  'shadow-md shadow-[var(--color-accent)]/30',
].join(' ');

For the progress ring, swapping the stroke color to match your accent is usually enough. But if you want to go further — say, a gradient stroke that transitions from blue to purple as the user scrolls — you'll need a linearGradient defined inside the SVG's <defs>. The gradient gets an id, and you reference it with stroke="url(#gradient)". It's about 8 extra lines of SVG and the effect is genuinely nice.

Worth noting: the gradient generator on Empire UI can help you pick gradient stop values if you're going the SVG gradient route. You can also pull color tokens from your existing design system and use CSS variables as the gradient stops — they work inside SVG when referenced via currentColor or stroke attributes directly.

In practice, I'd say the progress ring variant converts the best for content-heavy sites. Readers on long articles appreciate seeing how far they are, and it gives the button a secondary purpose beyond just navigation. It also makes the component feel intentional rather than tacked on.

Testing: What Actually Needs to Be Verified

What should you actually test here? The scroll listener cleanup is the most common source of bugs — if your component mounts and unmounts (like in a modal or a tab), you need the removeEventListener in the cleanup function. The hook above handles this, but it's worth a quick unit test.

import { render, act } from '@testing-library/react';
import { BackToTop } from './BackToTop';

it('shows button after scrolling past 400px', () => {
  const { getByRole } = render(<BackToTop />);

  // Button hidden initially
  expect(getByRole('button', { hidden: true })).toHaveAttribute(
    'aria-hidden',
    'true'
  );

  // Simulate scroll
  act(() => {
    Object.defineProperty(window, 'scrollY', { value: 500, writable: true });
    window.dispatchEvent(new Event('scroll'));
  });

  // Button now visible
  expect(getByRole('button')).not.toHaveAttribute('aria-hidden', 'true');
});

The aria-hidden assertion is the key one. If your test passes even when the button is invisible, your accessibility attributes aren't wired up right. Also test that clicking the button calls window.scrollTo with top: 0 — mock window.scrollTo before rendering and assert on it after the click.

For integration testing, Playwright makes scroll simulation straightforward: await page.evaluate(() => window.scrollTo(0, 1000)) followed by a visibility assertion on your button selector. This is where I'd invest test effort over unit tests — the component behavior is inherently visual and position-dependent.

If you're building out a component library and want to see more patterns like this, browse components to see how Empire UI handles similar interactive UI patterns. Also check out the parallax scrolling guide for more scroll-driven animation techniques that pair well with this component.

Dropping It Into Your Layout

The component should live as high in your tree as possible — typically in your root layout. In Next.js 14+ with the App Router, that means your app/layout.tsx. Don't put it inside a page component; you want it persisting across navigation.

// app/layout.tsx
import { BackToTop } from '@/components/back-to-top';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <BackToTop />
      </body>
    </html>
  );
}

If you're using a custom scroll container — like a full-viewport <div> with overflow-y: auto instead of document scroll — you'll need to pass a ref to the container and attach your event listener to ref.current rather than window. The hook needs a small refactor to accept an optional element parameter.

One more thing — z-index stacking. The z-50 class (which is z-index: 50 in Tailwind) is usually fine, but if you have modals or drawers that use higher z-index values, your button will appear behind them during transitions. A value around 9000-9500 is typically safe for "above everything except critical overlays." Just don't go z-[99999] — it creates a mess when you add more layers later.

FAQ

How do I make the back-to-top button work inside a scrollable div instead of the window?

Pass a ref to your scroll container into the hook and replace window with ref.current in both the event listener and the scroll position reads. document.documentElement.scrollHeight also needs to become ref.current.scrollHeight and window.innerHeight becomes ref.current.clientHeight.

Why does my progress ring flicker at 0% and 100%?

This usually happens when totalHeight is 0 — which occurs briefly before the page fully renders its content. Guard against it with totalHeight > 0 ? (y / totalHeight) * 100 : 0 as shown in the hook above. The transition duration on the SVG stroke also helps smooth rapid changes.

Should I use a button or an anchor tag for the back-to-top element?

Use a <button>. An anchor (<a href="#top">) creates a browser history entry so users have to press Back twice to actually leave the page — that's surprising behavior. A button with window.scrollTo avoids that. Make sure you keep aria-label="Back to top" since the button has no visible text.

Will the SVG progress ring work in Safari?

Yes, stroke-dasharray and stroke-dashoffset have had full Safari support since Safari 13 (2019). The only thing to watch is -rotate-90 via a Tailwind class — if you're targeting very old Safari, use transform: rotate(-90deg) in CSS instead of the utility class.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Accordion Component in React: Animated, Accessible, Compound PatternFree Stacked Cards Component for React — Cards Stack AnimationGSAP ScrollTrigger in React: Pinning, Scrubbing and Timeline SyncAnimated Number Counter in React: Stats That Count Up on Scroll