Skip to content

Tracks the article, not the page, and counts the minutes down.

Scrollmotion@number-flow/react

01Preview

Why interfaces feel slow

Field notes · Sep 2026

Why interfaces feel slow

Most interfaces feel slow long before they are slow. The request takes 180 milliseconds, but nothing on screen changes for the first 150 of them, so the press feels ignored. People don’t measure latency; they measure the gap between their hand and the screen answering it.

The fix is rarely a faster server. It is putting something on screen in the first fifty milliseconds: the button darkens, the row moves, the count ticks up. The result can arrive later. The acknowledgement cannot.

The same is true of hover and focus. A row that lights up under the pointer, a ring that appears the moment a key moves focus: these cost nothing to render and they tell people the interface is paying attention before anything has been asked of it.

Spinners are a promise

A spinner says “this will take a while”. Shown for 80 milliseconds, it reads as a glitch, and it makes a fast action feel slower than it was. Wait 150 milliseconds before showing one. Once it’s up, keep it for at least 300 so it never flickers.

For content, a spinner is almost always the wrong shape. A skeleton that matches the final layout tells people what is coming and where. When the data lands, nothing moves; it fills in.

Optimism, with a way back

Toggles, renames, reorders and likes rarely fail. Apply them on the same frame, send the request, and reconcile. If it fails, put the old value back and say what happened next to the thing that changed, not in a toast in the corner.

The revert matters as much as the optimism. If a rename fails, the old name comes back where the new one was, with a short line under it saying why, and the field stays open so the person can try again without retyping.

Keep this for actions people can see undone. Payments and deletes without an undo deserve an honest wait, with the busy state on the control they pressed, at the same width, so nothing around it jumps.

Where the answer goes

Feedback belongs where the eye already is. A save confirmed in a toast at the far corner of the screen asks people to look away from their work to learn that it worked. The button they pressed can say Saved for a second and a half instead, and nobody has to move their eyes.

Errors follow the same rule. A field that failed validation says so underneath itself, in words, with the fix. A request that failed says so beside the control that sent it, with a way to try again that keeps everything they typed.

Motion is not a loading state

An entrance animation can make a fast response feel slower. A panel that takes 400 milliseconds to slide in after the data has already arrived is latency you added on purpose. Keep motion short where people meet it often, and let it overlap the wait rather than follow it.

When something really does take time, show progress that moves with the work, not a loop that moves regardless. A bar that fills as files upload is honest; a shimmer that runs forever is decoration.

What to measure

Interaction to next paint is the number that matches the feeling. Test it on a throttled mid-range phone, not the laptop you built it on. If the first frame after a tap takes 200 milliseconds, no animation will hide it.

Measure the slow path too. Cold caches, a second tab competing for the main thread, a request that retries once: these are the cases people remember, because they are the ones where the interface went quiet and they didn’t know whether to wait or press again.

Then watch someone use it. The moments they press twice, or reach for the same button again, are the places the interface forgot to answer.

Filed under performance · 3 replies

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @number-flow/react

03Usage

import { ReadingProgress, ReadingProgressRing } from "@/components/ui/reading-progress";

const article = useRef<HTMLElement>(null);

<header className="sticky top-0 flex items-center">
  <h1>Why interfaces feel slow</h1>
  <ReadingProgressRing target={article} />
  <ReadingProgress target={article} className="absolute inset-x-0 -bottom-px" />
</header>
<article ref={article}></article>

04Source

"use client";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";

/** The nearest ancestor that scrolls vertically, or null for the page itself. */
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
  for (let node = el?.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
    if (/(auto|scroll|overlay)/.test(getComputedStyle(node).overflowY)) return node;
  }
  return null;
}

type Target = React.RefObject<HTMLElement | null>;

// The native scroll-driven timeline, where the browser has it. Not in the DOM typings yet.
type ViewTimelineCtor = new (options: { subject: Element; axis?: "block" | "inline" }) => AnimationTimeline;
const nativeViewTimeline = () => (typeof window === "undefined" ? undefined : (window as unknown as { ViewTimeline?: ViewTimelineCtor }).ViewTimeline);

export type ReadingProgressOptions = {
  /** The scroll container. Defaults to the nearest scrolling ancestor of the article, or the page. */
  scrollRoot?: Target;
  /** Reading speed used for the estimate. */
  wordsPerMinute?: number;
  /** A known reading time, in minutes, instead of counting words. Also lets the label render on the server. */
  minutes?: number;
};

type Visual = { ref: React.RefObject<Element | null>; keyframes: Keyframe[] };

/**
 * Tracks how far through an article the reader is: 0 when its top reaches the
 * top of the scroll container (below any scroll-padding, e.g. a sticky header),
 * 1 when its end reaches the bottom. The drawn indicator is driven by a native
 * view timeline where supported (off the main thread) and by the scroll
 * position otherwise; the number for the label and ARIA is always from script.
 */
function useProgressEngine(target: Target, { scrollRoot, wordsPerMinute = 230, minutes }: ReadingProgressOptions, visual?: Visual) {
  const [percent, setPercent] = useState(0);
  const [words, setWords] = useState(0);

  useEffect(() => {
    const article = target.current;
    if (!article) return;
    const scroller = scrollRoot?.current ?? getScrollParent(article);
    const source: HTMLElement | Window = scroller ?? window;
    const el = visual?.ref.current;
    const keyframes = visual?.keyframes ?? [];

    // One paused animation holds the indicator's keyframes. Script scrubs its
    // currentTime; a native timeline replaces the scrubbing entirely.
    let anim: Animation | undefined;
    let native = false;
    const TL = nativeViewTimeline();

    let padTop = 0;
    let padBottom = 0;
    let frame = 0;

    const view = () => {
      if (scroller) {
        const r = scroller.getBoundingClientRect();
        return { top: r.top + scroller.clientTop + padTop, height: scroller.clientHeight - padTop - padBottom };
      }
      return { top: padTop, height: window.innerHeight - padTop - padBottom };
    };

    const measure = () => {
      const cs = getComputedStyle(scroller ?? document.documentElement);
      padTop = parseFloat(cs.scrollPaddingTop) || 0;
      padBottom = parseFloat(cs.scrollPaddingBottom) || 0;
      if (!minutes) setWords((article.textContent ?? "").trim().split(/\s+/).filter(Boolean).length);

      // A timeline only matches our maths when the article is taller than the view.
      const tall = article.offsetHeight > view().height;
      const wantNative = !!TL && !!el && tall;
      if (el && (!anim || wantNative !== native)) {
        anim?.cancel();
        native = wantNative;
        anim = native
          ? el.animate(keyframes, { fill: "both", timeline: new TL!({ subject: article, axis: "block" }), rangeStart: "contain 0%", rangeEnd: "contain 100%" } as KeyframeAnimationOptions)
          : el.animate(keyframes, { duration: 1000, fill: "both" });
        if (!native) anim.pause();
      }
    };

    const update = () => {
      frame = 0;
      const v = view();
      const r = article.getBoundingClientRect();
      const span = r.height - v.height;
      const p = span > 0 ? Math.min(1, Math.max(0, (v.top - r.top) / span)) : r.bottom <= v.top + v.height ? 1 : 0;
      if (anim && !native) anim.currentTime = p * 1000;
      // State changes at most 100 times over the whole article.
      setPercent(Math.round(p * 100));
    };

    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    // Images loading and fonts swapping change the article's height; so do resizes.
    const ro = new ResizeObserver(() => {
      measure();
      onScroll();
    });
    ro.observe(article);
    if (scroller) ro.observe(scroller);
    source.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      ro.disconnect();
      source.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      cancelAnimationFrame(frame);
      anim?.cancel();
    };
  }, [target, scrollRoot, minutes, visual?.ref, visual?.keyframes]);

  const total = minutes ?? (words ? Math.max(1, Math.round(words / wordsPerMinute)) : 0);
  const finished = percent >= 100;
  const minutesLeft = total ? (finished ? 0 : Math.max(1, Math.ceil(total * (1 - percent / 100)))) : 0;
  return { percent, totalMinutes: total, minutesLeft, finished };
}

/** Progress through an article, without any UI: { percent, totalMinutes, minutesLeft, finished }. */
export function useReadingProgress(target: Target, options: ReadingProgressOptions = {}) {
  return useProgressEngine(target, options);
}

const valueText = (percent: number, minutesLeft: number, finished: boolean, finishedLabel = "Finished") =>
  finished ? finishedLabel : minutesLeft ? `${percent}%, ${minutesLeft} min left` : `${percent}%`;

const barKeyframes: Keyframe[] = [{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }];
const ringKeyframes: Keyframe[] = [{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }];

export type ReadingProgressProps = Omit<React.ComponentProps<"div">, "children"> &
  ReadingProgressOptions & {
    /** The article being read. Progress runs from its top to its end. */
    target: Target;
    /** Accessible name. */
    label?: string;
  };

/** A hairline bar that fills as the article is read. Position it with className. */
export function ReadingProgress({ target, scrollRoot, wordsPerMinute, minutes, label = "Reading progress", className, ...rest }: ReadingProgressProps) {
  const fill = useRef<HTMLDivElement>(null);
  const { percent, minutesLeft, finished } = useProgressEngine(target, { scrollRoot, wordsPerMinute, minutes }, { ref: fill, keyframes: barKeyframes });
  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={percent}
      aria-valuetext={valueText(percent, minutesLeft, finished)}
      data-state={finished ? "finished" : percent > 0 ? "reading" : "idle"}
      className={cn("pointer-events-none h-0.5 w-full overflow-hidden", className)}
      {...rest}
    >
      <div ref={fill} style={{ transform: "scaleX(0)" }} className="size-full origin-left bg-fg rtl:origin-right" />
    </div>
  );
}

export type ReadingProgressRingProps = Omit<React.ComponentProps<"div">, "children"> &
  ReadingProgressOptions & {
    target: Target;
    /** Show "4 min left" beside the ring. */
    showTimeLeft?: boolean;
    /** Shown in place of the time once the end is reached. */
    finishedLabel?: string;
    label?: string;
  };

/** A small ring with the time left, for a header or a floating pill. Draws a tick at the end. */
export function ReadingProgressRing({
  target,
  scrollRoot,
  wordsPerMinute,
  minutes,
  showTimeLeft = true,
  finishedLabel = "Finished",
  label = "Reading progress",
  className,
  ...rest
}: ReadingProgressRingProps) {
  const arc = useRef<SVGCircleElement>(null);
  const reduce = useReducedMotion();
  const { percent, minutesLeft, totalMinutes, finished } = useProgressEngine(target, { scrollRoot, wordsPerMinute, minutes }, { ref: arc, keyframes: ringKeyframes });

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={percent}
      aria-valuetext={valueText(percent, minutesLeft, finished, finishedLabel)}
      data-state={finished ? "finished" : percent > 0 ? "reading" : "idle"}
      className={cn("inline-flex items-center gap-2 text-[12px] text-fg-2", className)}
      {...rest}
    >
      <span className="relative grid size-[18px] shrink-0 place-items-center">
        <svg viewBox="0 0 18 18" className="absolute inset-0 -rotate-90 rtl:scale-x-[-1]" aria-hidden>
          <circle cx="9" cy="9" r="7.5" fill="none" strokeWidth="1.5" className="stroke-line-2" />
          <circle
            ref={arc}
            cx="9"
            cy="9"
            r="7.5"
            fill="none"
            strokeWidth="1.5"
            strokeLinecap="round"
            pathLength={1}
            strokeDasharray="1 1"
            strokeDashoffset={1}
            className="stroke-fg"
          />
        </svg>
        <AnimatePresence initial={false}>
          {finished && (
            // The ring closes, then fills and a tick draws inside it.
            <motion.span
              key="done"
              className="absolute inset-0 grid place-items-center rounded-full bg-fg text-frame"
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.14, ease: ease.in } }}
              transition={reduce ? { duration: 0.15 } : spring.pop}
            >
              <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
                <motion.path
                  d="M3.5 8.5 6.5 11.5 12.5 4.5"
                  initial={reduce ? false : { pathLength: 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{ duration: 0.3, ease: ease.out, delay: 0.08 }}
                />
              </svg>
            </motion.span>
          )}
        </AnimatePresence>
      </span>

      {showTimeLeft && (
        // Both states share one grid cell, so the width never jumps between them.
        <span aria-hidden className="grid whitespace-nowrap text-left">
          <span className="invisible col-start-1 row-start-1 tabular">{`${Math.max(totalMinutes, 10)} min left`}</span>
          <span className="invisible col-start-1 row-start-1">{finishedLabel}</span>
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              key={finished ? "finished" : totalMinutes ? "left" : "pending"}
              className="col-start-1 row-start-1"
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(2px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(2px)", transition: { duration: 0.14 } }}
              transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}
            >
              {finished ? (
                <span className="text-fg">{finishedLabel}</span>
              ) : totalMinutes ? (
                <NumberFlow value={minutesLeft} suffix=" min left" className="tabular" />
              ) : null}
            </motion.span>
          </AnimatePresence>
        </span>
      )}
    </div>
  );
}

05Props

ReadingProgress

PropTypeDefaultDescription
target*RefObject<HTMLElement | null>The article. Progress runs from its top reaching the top of the view to its end reaching the bottom.
scrollRootRefObject<HTMLElement | null>The scroll container. Defaults to the nearest scrolling ancestor of the article, or the page.
minutesnumberA known reading time instead of counting words, e.g. from your CMS.
wordsPerMinutenumber230Reading speed for the estimate.
labelstring"Reading progress"Accessible name.

ReadingProgressRing

PropTypeDefaultDescription
target*RefObject<HTMLElement | null>The article being read.
showTimeLeftbooleantrueShow the minutes left beside the ring.
finishedLabelstring"Finished"Replaces the time at the end.
scrollRoot · minutes · wordsPerMinute · labelAs on ReadingProgress.

useReadingProgress

PropTypeDefaultDescription
target*RefObject<HTMLElement | null>Returns { percent, totalMinutes, minutesLeft, finished } with no UI, for your own indicator.

06Notes

Behavior

  • Measures the article, not the page, so comments and footers below it don't hold the bar short of full.
  • Where the browser has native view timelines, the bar and ring are driven by one off the main thread; elsewhere a single scroll listener scrubs the same animation once per frame. The label and ARIA value always come from script and change at most 100 times over the article.
  • Respects scroll-padding on the container, so progress starts when the article passes under a sticky header, not behind it.
  • Re-measures when the article resizes (images loading, fonts swapping). An article shorter than the view reads 0 until its end is in view, then Finished.

Motion

  • The fill tracks the scroll exactly, with no easing: anything else lags the hand.
  • Minutes roll digit by digit as they change. At the end the ring fills on the pop spring from 0.6 and a tick draws in 300ms; the label swaps to Finished with a 4px rise and a 2px blur that clears.
  • Reduced motion keeps the tracking (it's information, not decoration) and turns the swaps into 150ms fades; the digits change without rolling.

Accessibility

  • Each indicator is a progressbar with aria-valuenow in whole percent and a value text like "42%, 3 min left".
  • Neither takes focus or announces while scrolling; screen reader users can query it when they want it.