# Scroll Progress

> Reading progress as a lit tube on the window edge, or a ring for back to top. Follows the page or any container.

Category: Feedback. Docs: https://neonkit.heyaadi.com/components/scroll-progress

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/scroll-progress.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/scroll-progress`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { ScrollProgress } from "@/components/ui/scroll-progress"

// Follows the page, pinned to the top of the window
<ScrollProgress />

// Follows a scroll container
<ScrollProgress target={articleRef} />
```

## Examples

### Article with back to top

A bar for the container, and a ring inside the back-to-top button that appears after 12%.

```tsx
"use client";

import * as React from "react";
import { ArrowUp } from "@/components/ui/icons";
import { ScrollProgress } from "@/components/ui/scroll-progress";

const sections = [
  {
    heading: "Where we are",
    body: "Relay delivers webhooks at most once. When a receiver times out after we've written the request, we mark the event failed and move on. Last quarter that lost 0.04% of events, about 1.9 million, and every one of them became a support ticket that started with “we never got it”.",
  },
  {
    heading: "Proposal",
    body: "Move to at-least-once delivery. Each event keeps a delivery id that survives retries, and receivers deduplicate on it. We retry on any non-2xx or timeout with jittered exponential backoff: 10 s, 40 s, 2.5 min, 10 min, then hourly for 24 hours.",
  },
  {
    heading: "What receivers have to do",
    body: "Store the Relay-Delivery-Id header for 72 hours and drop repeats. The SDKs for Node, Go and Python do this with a one-line Redis or Postgres adapter. Receivers that already key on our event id need no change, which by our logs is 71% of traffic.",
  },
  {
    heading: "Cost",
    body: "Retries add an estimated 3.8% to outbound volume at today's failure rate. Queue storage grows by 14 GB per region for the 24-hour window. Both fit inside current capacity; no new hardware.",
  },
  {
    heading: "Rollout",
    body: "Opt-in per endpoint from 4 November, default for new endpoints from 2 December, and default for everyone on 13 January after a month of dashboards showing duplicate rates per receiver.",
  },
];

export default function ScrollProgressDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const [percent, setPercent] = React.useState(0);

  return (
    <div className="relative w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-[#0a0b11]">
      <div className="border-b border-white/[0.06] px-5 pb-3 pt-4">
        <p className="text-[15px] font-medium text-white/90">RFC 0042: At-least-once webhooks</p>
        <p className="mt-0.5 text-[13px] text-white/50">Mira Okafor · 6 min read</p>
      </div>
      <ScrollProgress target={scrollRef} onProgressChange={setPercent} aria-label="RFC reading progress" />
      <div ref={scrollRef} tabIndex={0} aria-label="RFC 0042" className="h-80 overflow-y-auto px-5 py-5 outline-none [mask-image:linear-gradient(to_bottom,transparent,#000_20px)] [scrollbar-width:none]">
        {sections.map((section) => (
          <section key={section.heading} className="mb-6 last:mb-2">
            <h4 className="text-sm font-medium text-white/90">{section.heading}</h4>
            <p className="mt-1.5 text-[14px] leading-relaxed text-white/60">{section.body}</p>
          </section>
        ))}
      </div>
      <button
        type="button"
        aria-label="Back to top"
        tabIndex={percent > 12 ? 0 : -1}
        onClick={() => scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })}
        className={`absolute bottom-4 right-4 grid cursor-pointer place-items-center rounded-full bg-[#0a0b11]/90 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] ${
          percent > 12 ? "opacity-100" : "pointer-events-none translate-y-1 scale-[0.96] opacity-0"
        }`}
      >
        <ScrollProgress variant="ring" target={scrollRef} size={40}>
          <ArrowUp className="size-4" />
        </ScrollProgress>
      </button>
    </div>
  );
}
```

### Ring with a reading

onProgressChange reports whole percentages, so the label renders only when the number changes.

```tsx
"use client";

import * as React from "react";
import { ScrollProgress } from "@/components/ui/scroll-progress";

const steps = [
  ["Install the CLI", "npm i -g @relay/cli, then relay login. It opens a browser once and stores a token in your keychain."],
  ["Link the project", "relay link in your repo root writes .relay/project.json. Commit it; it holds no secrets."],
  ["Add an endpoint", "relay endpoints add https://api.example.com/hooks picks up the signing secret and prints it once."],
  ["Send a test event", "relay trigger invoice.paid replays a recorded payload against the endpoint and shows the response."],
  ["Turn on retries", "relay endpoints set --retries at-least-once. Receivers should deduplicate on Relay-Delivery-Id."],
  ["Watch it live", "relay tail streams deliveries with status, latency and attempt number. Ctrl-C to stop."],
];

export default function ScrollProgressRing() {
  const listRef = React.useRef<HTMLOListElement>(null);
  const [percent, setPercent] = React.useState(0);

  return (
    <div className="flex w-full max-w-md items-start gap-5">
      <ScrollProgress
        variant="ring"
        target={listRef}
        size={64}
        thickness={2.5}
        onProgressChange={setPercent}
        aria-label="Setup guide progress"
      >
        <span className="font-mono text-[13px] tabular-nums text-white/85">{percent}%</span>
      </ScrollProgress>
      <ol
        ref={listRef}
        tabIndex={0}
        aria-label="Setup guide"
        className="h-56 flex-1 space-y-4 overflow-y-auto pr-1 outline-none [scrollbar-width:thin]"
      >
        {steps.map(([title, body], i) => (
          <li key={title} className="text-sm">
            <p className="font-medium text-white/90">
              <span className="mr-2 font-mono text-white/40">{i + 1}</span>
              {title}
            </p>
            <p className="mt-1 leading-relaxed text-white/55">{body}</p>
          </li>
        ))}
      </ol>
    </div>
  );
}
```

### Thin, on a log

```tsx
"use client";

import * as React from "react";
import { ScrollProgress } from "@/components/ui/scroll-progress";

const lines = Array.from({ length: 28 }, (_, i) => {
  const t = new Date(Date.UTC(2026, 9, 17, 14, 2, 11) + i * 1370);
  const stamp = t.toISOString().slice(11, 19);
  const codes = [200, 200, 200, 202, 200, 409, 200, 200, 500, 200];
  const code = codes[i % codes.length];
  return `${stamp}  ${code}  POST /hooks/invoice.paid  ${32 + ((i * 37) % 180)}ms`;
});

export default function ScrollProgressLog() {
  const logRef = React.useRef<HTMLPreElement>(null);

  return (
    <div className="w-full max-w-md overflow-hidden rounded-xl border border-white/10 bg-[#07080d]">
      <div className="flex items-center justify-between px-4 py-2.5 text-[13px]">
        <span className="text-white/80">Deliveries</span>
        <span className="text-white/45">last 40 s</span>
      </div>
      <ScrollProgress color="lime" target={logRef} thickness={2} aria-label="Log position" />
      <pre
        ref={logRef}
        tabIndex={0}
        aria-label="Delivery log"
        className="h-48 overflow-auto px-4 py-3 font-mono text-[12px] leading-6 text-white/60 outline-none"
      >
        {lines.join("\n")}
      </pre>
    </div>
  );
}
```

### This page

Without a target it follows the window. This one is pinned to the bottom edge.

```tsx
import { ScrollProgress } from "@/components/ui/scroll-progress";

export default function ScrollProgressPage() {
  return (
    <div className="max-w-sm text-center">
      <ScrollProgress position="bottom" />
      <p className="text-sm leading-relaxed text-white/60">
        With no target it follows the page and pins itself to the window. This one sits on the bottom edge of
        your window while this page is open; scroll and watch it fill.
      </p>
    </div>
  );
}
```

## API reference

### ScrollProgress

A progressbar. Also accepts div attributes; children render in the middle of the ring.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `target` | `RefObject<HTMLElement>` | `the page` | A scroll container to follow. |
| `variant` | `"bar" \| "ring"` | `"bar"` | A tube along an edge, or a ring. |
| `fixed` | `boolean` | `!target` | Pin the bar to the viewport edge. |
| `position` | `"top" \| "bottom"` | `"top"` | Edge a fixed bar sits on. |
| `thickness` | `number` | `3 (bar), 2 (ring)` | Tube thickness in pixels. |
| `size` | `number` | `44` | Ring diameter in pixels. |
| `onProgressChange` | `(percent: number) => void` | — | Whole-number percentage, 0 to 100, when it changes. |
| `aria-label` | `string` | `"Reading progress"` | Name of the progressbar. |
| `color` | `NeonColor` | `inherited` | Color of the tube. |

## Accessibility

- It's a progressbar with aria-valuenow in whole percent, updated only when the number changes.
- Scroll writes go straight to the DOM once per frame: a translate on the lit tube for the bar, a dash and a rotation for the ring. Nothing lays out per frame and React doesn't re-render while you scroll.
- The lit tube is translated rather than scaled, so its white-hot tip and halo never stretch. At 100% the whole tube burns a little brighter.
- A ResizeObserver catches content that grows or shrinks without a scroll, such as images loading.
- Inside a button the ring's children are the button's content; give the button its own aria-label.

## Source

`components/ui/scroll-progress.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// Scroll writes straight to the DOM once per frame: a translate on the lit tube (the bar) or a dash and a
// rotation (the ring). React renders only when the whole-number percentage changes, for aria-valuenow.


function progressOf(target: HTMLElement | null) {
  if (target) {
    const range = target.scrollHeight - target.clientHeight;
    return range > 0 ? Math.min(1, Math.max(0, target.scrollTop / range)) : 0;
  }
  const doc = document.documentElement;
  const range = doc.scrollHeight - window.innerHeight;
  return range > 0 ? Math.min(1, Math.max(0, window.scrollY / range)) : 0;
}

export interface ScrollProgressProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** A scroll container to follow. Follows the page when omitted. */
  target?: React.RefObject<HTMLElement | null>;
  /** A tube along an edge, or a ring (for a back-to-top button). */
  variant?: "bar" | "ring";
  /** Pin the bar to the viewport edge. Defaults to true when following the page. */
  fixed?: boolean;
  /** Edge the fixed bar sits on. */
  position?: "top" | "bottom";
  /** Tube thickness in pixels. */
  thickness?: number;
  /** Ring diameter in pixels. */
  size?: number;
  /** Fires with the whole-number percentage, 0 to 100, when it changes. */
  onProgressChange?: (percent: number) => void;
}

const ScrollProgress = React.forwardRef<HTMLDivElement, ScrollProgressProps>(
  (
    {
      className,
      style,
      color,
      target,
      variant = "bar",
      fixed,
      position = "top",
      thickness,
      size = 44,
      onProgressChange,
      children,
      "aria-label": ariaLabel = "Reading progress",
      ...props
    },
    ref,
  ) => {
    const fillRef = React.useRef<HTMLDivElement>(null);
    const tipRef = React.useRef<HTMLSpanElement>(null);
    const arcRef = React.useRef<SVGCircleElement>(null);
    const haloRef = React.useRef<SVGCircleElement>(null);
    const rootRef = React.useRef<HTMLDivElement>(null);
    React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
    const [percent, setPercent] = React.useState(0);
    const report = React.useRef(onProgressChange);
    React.useEffect(() => {
      report.current = onProgressChange;
    });

    const ring = variant === "ring";
    const stroke = thickness ?? (ring ? 2 : 3);

    // A passive effect: a target that renders after this component only has its ref attached once layout
    // effects have run.
    React.useEffect(() => {
      let frame = 0;
      let lastPercent = -1;
      const node = target?.current ?? null;
      const write = () => {
        frame = 0;
        const p = progressOf(node);
        const root = rootRef.current;
        if (ring) {
          const on = p > 0.004;
          arcRef.current?.setAttribute("stroke-dasharray", `${p} 2`);
          haloRef.current?.setAttribute("stroke-dasharray", `${p} 2`);
          arcRef.current?.setAttribute("opacity", on ? "1" : "0");
          haloRef.current?.setAttribute("opacity", on ? "1" : "0");
        } else if (fillRef.current) {
          fillRef.current.style.transform = `translate3d(${(p - 1) * 100}%, 0, 0)`;
        }
        if (tipRef.current) {
          tipRef.current.style.opacity = String(Math.min(1, p * 25));
          if (ring) tipRef.current.style.transform = `rotate(${p * 360}deg)`;
        }
        if (root) {
          if (p >= 0.999) root.setAttribute("data-complete", "");
          else root.removeAttribute("data-complete");
        }
        const next = Math.round(p * 100);
        if (next !== lastPercent) {
          lastPercent = next;
          setPercent(next);
          report.current?.(next);
        }
      };
      const queue = () => {
        if (!frame) frame = requestAnimationFrame(write);
      };
      write();
      const scroller: HTMLElement | Window = node ?? window;
      scroller.addEventListener("scroll", queue, { passive: true });
      window.addEventListener("resize", queue);
      // Content that grows or shrinks moves the end of the page without a scroll.
      const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(queue);
      const content = node ? [node, ...Array.from(node.children)] : [document.body];
      content.forEach((el) => ro?.observe(el));
      return () => {
        cancelAnimationFrame(frame);
        scroller.removeEventListener("scroll", queue);
        window.removeEventListener("resize", queue);
        ro?.disconnect();
      };
    }, [target, ring]);

    const pinned = fixed ?? !target;
    const a11y = {
      role: "progressbar",
      "aria-label": ariaLabel,
      "aria-valuemin": 0,
      "aria-valuemax": 100,
      "aria-valuenow": percent,
    } as const;

    if (ring) {
      const r = (size - stroke * 4) / 2;
      const c = size / 2;
      return (
        <div
          ref={rootRef}
          {...a11y}
          style={neonVars(color, { width: size, height: size, ...style })}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "group/scroll-progress relative inline-grid shrink-0 place-items-center rounded-full text-white/80",
            className,
          )}
          {...props}
        >
          <svg aria-hidden width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 -rotate-90 overflow-visible">
            <circle cx={c} cy={c} r={r} fill="none" strokeWidth={stroke} style={{ stroke: "rgb(var(--neon) / 0.14)" }} />
            <circle
              ref={haloRef}
              cx={c}
              cy={c}
              r={r}
              fill="none"
              pathLength={1}
              strokeDasharray="0 2"
              strokeLinecap="round"
              opacity={0}
              strokeWidth={stroke * 3.5}
              className="[stroke:rgb(var(--neon)/CALC(0.16*var(--neon-glow,1)))] group-data-[complete]/scroll-progress:[stroke:rgb(var(--neon)/CALC(0.3*var(--neon-glow,1)))]"
            />
            <circle
              ref={arcRef}
              cx={c}
              cy={c}
              r={r}
              fill="none"
              pathLength={1}
              strokeDasharray="0 2"
              strokeLinecap="round"
              opacity={0}
              strokeWidth={stroke}
              style={{ stroke: "rgb(var(--neon))" }}
            />
          </svg>
          {/* The white-hot head rides the end of the arc. */}
          <span ref={tipRef} aria-hidden className="pointer-events-none absolute inset-0 opacity-0">
            <span
              className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_6px_1px_rgb(var(--neon)/var(--neon-glow,1))]"
              style={{ top: stroke * 2, width: stroke * 1.6, height: stroke * 1.6 }}
            />
          </span>
          <span className="relative">{children}</span>
        </div>
      );
    }

    return (
      <div
        ref={rootRef}
        {...a11y}
        style={neonVars(color, { height: stroke, ...style })}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "group/scroll-progress pointer-events-none w-full",
          pinned ? cn("fixed inset-x-0 z-50", position === "top" ? "top-0" : "bottom-0") : "relative",
          className,
        )}
        {...props}
      >
        {/* Unlit glass along the whole edge. */}
        <div aria-hidden className="absolute inset-0 bg-[rgb(var(--neon)/0.12)]" />
        {/* Clipped at the ends only, so the halo still spills above and below the tube. */}
        <div aria-hidden className="absolute inset-0 [clip-path:inset(-16px_0_-16px_0)]">
          <div ref={fillRef} className="absolute inset-0 will-change-transform" style={{ transform: "translate3d(-100%, 0, 0)" }}>
            <div className="absolute inset-0 bg-[linear-gradient(90deg,rgb(var(--neon)/0.55),rgb(var(--neon))_70%)] shadow-[0_0_8px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1))),0_0_2px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))] transition-shadow duration-300 group-data-[complete]/scroll-progress:shadow-[0_0_14px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1))),0_0_2px_rgb(var(--neon)/var(--neon-glow,1))]" />
            <span ref={tipRef} className="absolute right-0 top-1/2 opacity-0">
              <span className="absolute size-7 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,rgb(var(--neon)/CALC(0.5*var(--neon-glow,1))),transparent)]" />
              <span
                className="absolute -translate-x-full -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,transparent,#fff)]"
                style={{ width: 18, height: stroke }}
              />
            </span>
          </div>
        </div>
        {children}
      </div>
    );
  },
);
ScrollProgress.displayName = "ScrollProgress";

export { ScrollProgress };
```
