Reading Progress

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

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 { motion, useScroll, useSpring, useMotionValueEvent } from 'framer-motion';
import { useEffect, useState, type RefObject } from 'react';

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// target           Optional element ref to track (defaults to document)
// position         Where the bar lives: top | bottom | inline
// thickness        Bar height in px
// color            CSS color for the filled track
// showPercentage   Fade in a tiny % label while scrolling
// progress         Controlled 0–1 value — skips scroll tracking when set
// className        Styles for the outer shell
//
type ReadingProgressProps = {
  target?: RefObject<HTMLElement | null>;
  position?: 'top' | 'bottom' | 'inline';
  thickness?: number;
  color?: string;
  showPercentage?: boolean;
  progress?: number;
  className?: string;
};

export default function ReadingProgress({
  target,
  position = 'inline',
  thickness = 2,
  color = '#111827',
  showPercentage = true,
  progress,
  className = '',
}: ReadingProgressProps) {
  const controlled = typeof progress === 'number';
  const { scrollYProgress } = useScroll(
    target ? { target, offset: ['start start', 'end end'] } : undefined
  );
  const smooth = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 28,
    mass: 0.35,
  });

  const [pct, setPct] = useState(
    controlled ? Math.round(Math.max(0, Math.min(1, progress)) * 100) : 0
  );
  const [active, setActive] = useState(controlled ? progress > 0.02 : false);

  useMotionValueEvent(smooth, 'change', (v) => {
    if (controlled) return;
    const next = Math.round(v * 100);
    setPct(next);
    setActive(v > 0.02 && v < 0.98);
  });

  useEffect(() => {
    if (!controlled) return;
    const clamped = Math.max(0, Math.min(1, progress));
    setPct(Math.round(clamped * 100));
    setActive(clamped > 0.02);
  }, [controlled, progress]);

  const fixed =
    position === 'top'
      ? 'fixed left-0 right-0 top-0 z-50'
      : position === 'bottom'
        ? 'fixed bottom-0 left-0 right-0 z-50'
        : 'relative w-full';

  return (
    <div
      className={[fixed, 'pointer-events-none', className]
        .filter(Boolean)
        .join(' ')}
      role='progressbar'
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={pct}
      aria-label='Reading progress'
    >
      <div
        className='relative w-full overflow-hidden bg-gray-200/60'
        style={{ height: thickness }}
      >
        {controlled ? (
          <motion.div
            className='absolute inset-y-0 left-0'
            style={{ backgroundColor: color }}
            initial={{ width: 0 }}
            animate={{ width: `${pct}%` }}
            transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
          />
        ) : (
          <motion.div
            className='absolute inset-y-0 left-0 w-full origin-left'
            style={{ backgroundColor: color, scaleX: smooth }}
          />
        )}
      </div>

      {showPercentage ? (
        <motion.span
          className={[
            'absolute right-3 font-mono text-[10px] tracking-wider text-gray-500',
            position === 'bottom' ? '-top-5' : 'top-2',
          ].join(' ')}
          animate={{ opacity: active ? 1 : 0, y: active ? 0 : -4 }}
          transition={{ duration: 0.25 }}
        >
          {pct}%
        </motion.span>
      ) : null}
    </div>
  );
}

```

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 CSSFramer Motion

Props

PropTypeDescription
targetRefObject<HTMLElement | null>Element ref to track; defaults to the document
position'top' | 'bottom' | 'inline'Where the bar lives
thicknessnumberBar height in px
colorstringCSS color for the filled track
showPercentagebooleanFade in a tiny % label while scrolling
progressnumberControlled 0–1 value — skips scroll tracking when set
classNamestringStyles for the outer shell

More Content Components

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

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.