Scroll Progress Indicator

A circular scroll progress indicator that doubles as a compact return-to-top control.

Add to your project

Pick the way you like to build.

Let your AI assistant add it

Copy this prompt and paste it into the AI tool building your site.

# Integrate this UI component

Add this component to my project.

1. Install any missing dependencies.
2. Make sure Tailwind CSS is set up and scans the new file.
3. Prefer passing props over editing the component source — changing the component itself may break it.
4. Adjust colors, size, and layout via props or wrapper classes to match my project. You know my codebase better than this snippet does.

## Component source

```tsx
'use client';

import { useEffect, useState, type RefObject } from 'react';
import { ArrowUp } from 'lucide-react';

type ScrollCompassProps = {
  target?: RefObject<HTMLElement | null>;
  progress?: number;
  position?: 'left' | 'right' | 'inline' | 'absolute';
  size?: number;
  scrollToTop?: boolean;
  className?: string;
};

const clamp = (value: number) => Math.min(1, Math.max(0, value));

export default function ScrollCompass({
  target,
  progress,
  position = 'right',
  size = 64,
  scrollToTop = true,
  className = '',
}: ScrollCompassProps) {
  const controlled = typeof progress === 'number';
  const [value, setValue] = useState(() => clamp(progress ?? 0));
  const percentage = Math.round(value * 100);
  const circumference = 2 * Math.PI * 42;

  useEffect(() => {
    if (controlled) {
      setValue(clamp(progress ?? 0));
      return;
    }

    const update = () => {
      const element = target?.current;
      const height = element
        ? element.scrollHeight - element.clientHeight
        : document.documentElement.scrollHeight - window.innerHeight;
      const offset = element ? element.scrollTop : window.scrollY;
      setValue(height > 0 ? clamp(offset / height) : 0);
    };

    update();
    const element = target?.current;
    const source = element ?? window;
    source.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    return () => {
      source.removeEventListener('scroll', update);
      window.removeEventListener('resize', update);
    };
  }, [controlled, progress, target]);

  const placement =
    position === 'left'
      ? 'fixed bottom-6 left-6 z-50'
      : position === 'right'
        ? 'fixed bottom-6 right-6 z-50'
        : position === 'absolute'
          ? 'absolute bottom-10 right-10 z-10'
          : 'relative';

  return (
    <button
      type='button'
      className={[
        placement,
        'group grid place-items-center rounded-full bg-white shadow-[0_12px_32px_rgb(15_23_42/0.16)] ring-1 ring-slate-200 transition-transform hover:-translate-y-0.5 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
        className,
      ].join(' ')}
      style={{ width: size, height: size }}
      aria-label={`Scroll progress: ${percentage}%.${scrollToTop ? ' Return to top.' : ''}`}
      onClick={() => {
        if (!scrollToTop) return;
        const element = target?.current;
        if (element) element.scrollTo({ top: 0, behavior: 'smooth' });
        else window.scrollTo({ top: 0, behavior: 'smooth' });
      }}
    >
      <svg aria-hidden='true' viewBox='0 0 100 100' className='absolute inset-1 h-[calc(100%-0.5rem)] w-[calc(100%-0.5rem)] -rotate-90'>
        <circle cx='50' cy='50' r='42' fill='none' stroke='currentColor' strokeWidth='7' className='text-slate-100' />
        <circle
          cx='50'
          cy='50'
          r='42'
          fill='none'
          stroke='currentColor'
          strokeWidth='7'
          strokeLinecap='round'
          className='text-indigo-600 transition-[stroke-dashoffset] duration-150'
          strokeDasharray={circumference}
          strokeDashoffset={circumference * (1 - value)}
        />
      </svg>
      <span
        className={`relative text-xs font-semibold tabular-nums text-slate-800 transition-opacity ${scrollToTop ? 'group-hover:opacity-0 group-focus-visible:opacity-0' : ''
          }`}
      >
        {percentage}%
      </span>
      {scrollToTop ? (
        <ArrowUp
          aria-hidden='true'
          className='absolute size-5 scale-75 text-slate-800 opacity-0 transition-[opacity,transform] group-hover:scale-100 group-hover:opacity-100 group-focus-visible:scale-100 group-focus-visible:opacity-100'
        />
      ) : null}
      {scrollToTop ? <span className='sr-only'>Return to top</span> : null}
    </button>
  );
}

```

Works with your AI tools

ClaudeClaude*
CodexCodex*
LovableLovable
Base44Base44
Figma MakeFigma Make
ReplitReplit*
CursorCursor*
v0v0

* Compatible with most projects that use the default React/Next.js framework.

Built With

ReactTailwind CSSSVG

Props

PropTypeDescription
targetRefObject<HTMLElement | null>Optional scrollable element ref; defaults to window scroll tracking
progressnumberControlled progress from 0 to 1; skips scroll tracking when set
position'left' | 'right' | 'inline' | 'absolute'Where the compass is placed
sizenumberDiameter in pixels
scrollToTopbooleanWhether clicking it smoothly returns the reader to the top
classNamestringClasses appended to the outer button

More Content Components

62%

A drawn-line progress indicator with spring-smoothed scroll tracking and a fading percentage.

The quietest interfaces often carry the most conviction.
Elena Voss
Design Director, Lattice

Editorial pull quote with oversized glyph, expanding rule, and blurred fade-in.