Framework-native navigation progress bar for vinext. Detects route transitions through the router itself — no history monkey-patching, no stuck bars, ~2 kB gzipped, accessible by default.
This is an independent community package by WillBooster Inc. and is not affiliated with or endorsed by Cloudflare or the vinext project.
Existing top loaders finish the bar by monkey-patching window.history.pushState. vinext's App Router deliberately bypasses external history patches (it commits URLs through a captured native pushState to avoid spurious re-renders), so those loaders start the bar but never finish it — the bar trickles forever.
vinext-progress takes the opposite approach:
- Finish — observes the URL commit through
usePathname()/useSearchParams(), which vinext (and Next.js) guarantee to update exactly once per committed navigation. Covers<Link>,router.push/replace,redirect(), server actions, and back/forward. No patching. - Start — a document-level click listener that mirrors the router's own link-acceptance rules (same-origin, not
target=_blank, notdownload, not modified clicks, not hash-only), a submit listener for GET form navigations (next/form), plus a wrapper around vinext's publicappRouterInstanceso programmaticrouter.push/router.replacealso show the bar. On vinext, any navigation the router itself reports as in-flight also starts the bar — covering back/forward traversals that refetch, server-action redirects,router.refresh(), and same-URL navigations (which vinext really does refetch). Disable withtrackRouterReportedNavigations={false}. - Never stuck — a settlement watcher on vinext's window-global navigation state finishes the bar even when the committed URL equals the starting URL (e.g.
redirect()back to the current page). A stall timeout clears starts that never become a navigation (e.g. a click canceled by user code), and once a real navigation is observed it is replaced by a longer in-flight timeout so slow navigations are not cut off but a hung fetch still cannot pin the bar forever.
Additional properties:
- ~2 kB gzipped (~5 kB minified), zero dependencies, no nprogress.
- No CSS injection at all (inline styles only) — no
<style>tags or runtime stylesheets. Note: a strict nonce/hash-based CSP blocks inline style attributes, so the bar needsstyle-src 'unsafe-inline'(or'unsafe-hashes'with the style hashes) to be visible. role="progressbar"with livearia-valuenow; respectsprefers-reduced-motion.- ESM-only with proper
use clientdirectives — no CJS interop hacks under Vite/Rolldown.
Add the component once to your root layout:
// app/layout.tsx
import { NavigationProgress } from 'vinext-progress';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NavigationProgress color="var(--your-brand-color)" />
{children}
</body>
</html>
);
}| Prop | Default | Description |
|---|---|---|
color |
#29d |
Bar color (any CSS color, including var(...)) |
height |
3 |
Bar height in px |
zIndex |
1600 |
Stacking order |
ariaLabel |
'Page navigation' |
Accessible name |
trackLinkClicks |
true |
Start at click time on same-origin link clicks¹ |
trackFormSubmits |
true |
Start at submit time on same-origin GET form submissions (next/form)¹ |
trackRouterCalls |
true |
Start at call time on router.push/replace (vinext only; no-op on plain Next)¹ |
trackRouterReportedNavigations |
true |
Start whenever vinext reports an in-flight navigation (back/forward, server-action redirects, router.refresh()) |
minimum |
0.08 |
Initial progress value |
maximum |
0.994 |
Trickle upper bound |
trickleSpeed |
200 |
ms between trickle increments |
speed |
200 |
CSS transition duration (ms) |
stallTimeoutMs |
10000 |
Auto-finish timeout for starts with no observed navigation (ms) |
inFlightTimeoutMs |
30000 |
Auto-finish timeout once a real navigation is in flight (ms) |
¹ These three props only remove the early, interaction-time start signal. On vinext the resulting navigation is still reported by the router and starts the bar unless trackRouterReportedNavigations is also false; set all four to false for fully manual control.
'use client';
import { useNavigationProgress } from 'vinext-progress';
const progress = useNavigationProgress();
progress.start();
progress.set(0.5);
progress.finish();A manually started bar is not subject to the automatic stall/in-flight timeouts — it trickles until you call finish() (or reset()), so long-running work is never cut off. Exception: a navigation that starts while the manual bar is showing takes the bar over and finishes it when the navigation commits or settles. isActive() is a point-in-time read, not reactive state.
- A link click or GET form submission whose navigation is canceled via
event.preventDefault()in user code (e.g. a search form doing client-side filtering) briefly starts the bar; the stall timeout clears it. (Distinguishing this case is impossible at the document level because the router itself callspreventDefault()for every client-side navigation.) Opt out withtrackLinkClicks={false}/trackFormSubmits={false}. - With a
basePath, programmatic same-URL detection may start the bar unnecessarily; the settlement watcher, commit watcher, or stall timeout clears it. - Same-URL navigations the router actually performs (a
<Link>to the current URL, a same-destination GET form submit) briefly show the bar: vinext refetches the page, the router reports the navigation, and the settlement watcher starts and then finishes the bar.
bun install
bun run build # tsc → dist/
bun run test:unit # bun test (progress engine)
bun run test:e2e # Playwright against a real vinext app in e2e/fixtureApache License 2.0 © WillBooster Inc.