What this solves
Visual and information: a click needs to register immediately, but a spinner on every transition would flash on the instant ones. The staged timing tells the truth either way, silent under 90ms, then trickling so the user knows the click landed.
Use it when
- A fast, client-side route change that resolves in under 90msspeedway root.tsx: the grace period keeps the bar from flashing at all
- A slower navigation whose data is still loadingspeedway root.tsx: the same NavProgressBar trickles toward 88% until the route lands
Rendered
Fixture data; check both themes.Idle: the frame at rest, before any navigation starts.
Misfires and silences
What speedway's real bar got wrong before it settled, each entry a shipped commit.- Silent under a real clickThe original feedback was a cursor: progress on the whole shell plus a 60%-opacity pulse on the clicked sidebar link, riding an indeterminate CSS sweep with no real percentage. None of it reliably told the user their click had landed, which is why the commit that replaced it is titled, verbatim, "navigation feedback that says something."
speedway 92ac9d3 · app/root.tsx:219-259, app/styles/app.css:1104-1121 - The bar stayed lit on data the first frame never neededThe jobs route's loader awaited two Firestore count queries synchronously, so the whole navigation, and the bar with it, stayed in the loading state until both resolved, even though the job rows themselves streamed in on a separate deferred promise that was ready sooner. Moving the counts into that same deferred promise let the destination's skeleton become the first frame of the navigation instead.
speedway 92ac9d3 · app/routes/workspaces/jobs/jobs.tsx:41-79, 87-97 - A sibling spinner flashed on the exact transitions the bar was built to keep silentThe sidebar's per-link spinner, added in the same commit as the 90ms-gated top bar, had no grace of its own, so an instant navigation could still flash it beside the clicked label while the top bar correctly stayed invisible. Twenty-eight minutes later the rail spinner got its own delay, 120ms, so the two feedback channels agree on what counts as instant.
speedway c8ed2ef · app/components/WorkspaceNav.tsx:114-144 - Clicking one tab lit the pending spinner on several unrelated onesReact Router's built-in isPending matches by pathname prefix only, so the Home link matched every workspace sub-page, and the five module tabs, which share one route and differ only by an m search param, all reported pending together whenever any one of them was clicked. The fix compares the exact pathname plus the m param instead, with a per-link override for anything that needs a different rule.
speedway 309f51a · app/components/WorkspaceNav.tsx:34-62, 68-112
Walmart-mvp has no route-level loading bar to compare against. Its only progress bar lives in the catalog enrichment tab and tracks a background scrape run, not navigation.
patterns/top-loading-bar/example.tsxCopy this into a fresh route and it renders as above.
"use client";import { Button } from "@versable-git/ui";import { useEffect, useRef, useState } from "react";// The nprogress idea without the corner spinner: wait 90ms so instant// transitions never flash the bar, trickle toward 88%, and only hit 100// when the navigation actually lands. Replay runs the three phases from// idle: grace, trickle, complete.type Phase = "idle" | "grace" | "trickle" | "complete";const PHASE_TEXT: Record<Phase, string> = { idle: "Idle: the frame at rest, before any navigation starts.", grace: "Grace (90ms): waiting this out before showing anything, so an instant transition never flashes the bar.", trickle: "Trickle: eases toward 88% while the destination is still loading.", complete: "Complete: the navigation landed, the bar jumps to 100% then fades.",};export function TopLoadingBar() { const [phase, setPhase] = useState<Phase>("idle"); const [pct, setPct] = useState(0); const timers = useRef<{ grace?: number; trickle?: number; land?: number; fade?: number }>({}); function clearTimers() { const t = timers.current; window.clearTimeout(t.grace); window.clearInterval(t.trickle); window.clearTimeout(t.land); window.clearTimeout(t.fade); } function replay() { clearTimers(); setPct(0); setPhase("idle"); const t = timers.current; t.grace = window.setTimeout(() => { setPhase("trickle"); setPct(14); t.trickle = window.setInterval(() => { setPct((p) => p + (90 - p) * 0.14); }, 200); }, 90); t.land = window.setTimeout(() => { window.clearInterval(t.trickle); setPhase("complete"); setPct(100); t.fade = window.setTimeout(() => { setPhase("idle"); setPct(0); }, 350); }, 2500); } useEffect(() => clearTimers, []); const shown = phase !== "idle"; return ( <div className="flex flex-col gap-4"> <div className="border-base-300 bg-base-100 relative h-24 overflow-hidden rounded-lg border"> <div aria-hidden="true" className="bg-primary absolute top-0 left-0 h-0.5 transition-[width,opacity] duration-200" style={{ width: `${pct}%`, opacity: shown ? 1 : 0 }} /> <div className="text-base-content/70 flex h-full items-center justify-center px-6 text-center text-sm"> {PHASE_TEXT[phase]} </div> </div> <div> <Button size="sm" Icon="Refresh" content="Replay" onClick={replay} /> </div> </div> );}Where it ships
speedway/app/root.tsx:219-259NavProgressBar: the 90ms grace, the eased trickle toward 88%, the jump-and-fade on arrivalspeedway/app/styles/app.css:1104-1121the .nav-progress rules the app version styles with
App-specific: The real bar keys off the router's navigation state and mounts once in the shell at viewport top; the frame here bounds it for display. Zero speedway-specific logic, which is why it is a standing kit-component candidate (ruling D7).