vinext-progress
v1.0.0
Published
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.
Readme
vinext-progress
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.
Why not nextjs-toploader / holy-loader?
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.
Usage
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>
);
}Props
| 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.
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.
Known limitations
- 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.
Development
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/fixtureLicense
Apache License 2.0 © WillBooster Inc.
