From 743da215646dbea5ebc4b4901fed29257c49b3c9 Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:26:04 +1200 Subject: [PATCH 1/7] Correct the completion comment about done() The comment claimed the original nprogress `done()` includes a random progress jump that we omit, implying a visible divergence. The jump exists in the original's code but never renders: its queue shifts each callback out of `pending` before running it, so the jump and the go-to-1 step both execute in the same tick and the second CSS write overwrites the first before paint. Measured side by side, both complete in identical fill and fade windows. Also drop the suggestion that consumers can set a higher progress value before stopping the animation. The hook exposes no progress setter, so that guidance cannot be followed. Co-Authored-By: Claude Opus 5 --- src/useNProgress.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/useNProgress.tsx b/src/useNProgress.tsx index e19f55d04..270f3da95 100644 --- a/src/useNProgress.tsx +++ b/src/useNProgress.tsx @@ -29,10 +29,10 @@ const initialState: State = { const reducer = (state: State, action: Action): State => { switch (action.type) { case 'complete': - // Unlike the original nprogress `done()`, completion does not include a - // random progress jump before animating to 1. This keeps the primitive - // predictable; consumers can set a higher progress value before stopping - // the animation if they want that effect. + // The original nprogress `done()` computes a random progress jump before + // animating to 1, but its queue runs both steps in the same tick, so the + // jump's CSS is overwritten before paint and never renders. Omitting the + // jump changes nothing visually. // // Ignored unless an animation is actually running, which is what makes a // StrictMode double-mount a no-op rather than a spurious completion. From 08c4f94ba33982649ab905e7d2f64559183ad630 Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:26:04 +1200 Subject: [PATCH 2/7] Ignore Next build output in lint and format checks Running a Next example's dev server leaves generated output in examples/*/.next. It is gitignored per example, but neither prettier nor eslint reads those files, so a subsequent `npm test` at the repo root failed in check:format and lint on thousands of generated-file problems. Co-Authored-By: Claude Opus 5 --- .prettierignore | 1 + eslint.config.mjs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 650ea326d..dc466ec66 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ +.next README.md coverage dist diff --git a/eslint.config.mjs b/eslint.config.mjs index 87b89564e..5ec096f63 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,7 @@ import tseslint from 'typescript-eslint' export default tseslint.config( { - ignores: ['**/coverage/', '**/dist/', '**/node_modules/'], + ignores: ['**/.next/', '**/coverage/', '**/dist/', '**/node_modules/'], }, js.configs.recommended, ...tseslint.configs.recommended, From 63728102a910e5054936989679ed6a549bf05319 Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:34:08 +1200 Subject: [PATCH 3/7] Start the bar at `minimum` The original nprogress `start()` calls set(0), which clamps to `minimum`, so the bar first paints at 0.08 and the first two trickles are +0.1 each: 0.08, 0.18, 0.28. Our `start` action used increment(0) = 0.1, so the second trickle crossed into the +0.04 tier immediately: 0.1, 0.2, 0.24. Measured side by side against nprogress master, the two curves ran 2-4 points apart for the first few seconds. Set the start progress to clamp(0, minimum, 1) instead. Verified in a browser against the real nprogress master build: the sampled values now match at every trickle. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/useNProgress.tsx | 9 +++------ test/NProgress.spec.tsx | 2 +- test/useNProgress.spec.ts | 35 ++++++++++++++++------------------- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 182536fdf..37e231b27 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Whether the bar is running. Going `true` starts it, going `false` completes it. #### `minimum` -Lower bound for `progress`, between `0` and `1`. The first increment starts from `0.1` rather than from `0`, so the bar appears at `max(0.1, minimum)` and the option only shows through when it is set above `0.1`. Changing it while the bar is animating does not rewind the bar. Progress holds where it is, and the new bound applies from the next increment. +Lower bound for `progress`, between `0` and `1`. The bar first appears at this value, then trickles up from there. Changing it while the bar is animating does not rewind the bar. Progress holds where it is, and the new bound applies from the next increment. ### Return Value diff --git a/src/useNProgress.tsx b/src/useNProgress.tsx index 270f3da95..66fc9b99e 100644 --- a/src/useNProgress.tsx +++ b/src/useNProgress.tsx @@ -44,11 +44,8 @@ const reducer = (state: State, action: Action): State => { return { phase: 'finished', progress: 1 } case 'start': - // The original nprogress calls set(0) - which clamps to `minimum` - - // before the first trickle. Here, the first trickle starts from - // increment(0) = 0.1, so the bar appears at max(0.1, minimum) rather - // than exactly `minimum`. The difference is negligible at the default - // minimum of 0.08. + // Matches the original nprogress `start()`, which calls set(0) and so + // paints first at `minimum` before any trickle runs. // // Guarded the same way as `complete`, and for the same reason: a repeat // dispatch against a running animation must not rewind the bar. That @@ -58,7 +55,7 @@ const reducer = (state: State, action: Action): State => { ? state : { phase: 'animating', - progress: clamp(increment(0), action.minimum, 1), + progress: clamp(0, action.minimum, 1), } case 'trickle': diff --git a/test/NProgress.spec.tsx b/test/NProgress.spec.tsx index 997178539..65f1cf554 100644 --- a/test/NProgress.spec.tsx +++ b/test/NProgress.spec.tsx @@ -38,5 +38,5 @@ test('passes animating state to children', () => { ) expect(isFinished).toBe(false) - expect(progress).toBe(0.1) + expect(progress).toBe(0.08) }) diff --git a/test/useNProgress.spec.ts b/test/useNProgress.spec.ts index f4a3bbd56..8468c2b83 100644 --- a/test/useNProgress.spec.ts +++ b/test/useNProgress.spec.ts @@ -35,7 +35,7 @@ test('starts animating when isAnimating is true', () => { expect(result.current).toEqual({ animationDuration: 200, isFinished: false, - progress: 0.1, + progress: 0.08, }) unmount() @@ -52,7 +52,7 @@ test('starts animating when isAnimating changes from false to true', () => { expect(result.current).toEqual({ animationDuration: 200, isFinished: false, - progress: 0.1, + progress: 0.08, }) unmount() @@ -71,7 +71,7 @@ test('increments correctly', () => { expect(result.current).toEqual({ animationDuration: 200, isFinished: false, - progress: 0.2, + progress: 0.18, }) unmount() @@ -128,7 +128,7 @@ test('correctly restarts a finished animation', () => { expect(result.current).toEqual({ animationDuration: 200, isFinished: false, - progress: 0.2, + progress: 0.18, }) unmount() @@ -139,7 +139,7 @@ test('respects custom minimum', () => { useNProgress({ isAnimating: true, minimum: 0.3 }), ) - // increment(0) = 0.1, clamped to minimum of 0.3. + // The start value of 0 is clamped up to the minimum of 0.3. expect(result.current.progress).toBe(0.3) unmount() @@ -211,11 +211,11 @@ test('starts and trickles once under StrictMode', () => { expect(result.current).toEqual({ animationDuration: 200, isFinished: false, - progress: 0.1, + progress: 0.08, }) // A duplicate timer from the dev double-mount would trickle twice here, - // taking progress to increment(0.2) = 0.24. + // taking progress to increment(0.18) = 0.28. act(() => { mockRaf.step() mockRaf.step({ time: 201 }) @@ -224,7 +224,7 @@ test('starts and trickles once under StrictMode', () => { expect(result.current).toEqual({ animationDuration: 200, isFinished: false, - progress: 0.2, + progress: 0.18, }) unmount() @@ -279,7 +279,7 @@ test('respects custom incrementDuration', () => { useNProgress({ incrementDuration: 500, isAnimating: true }), ) - expect(result.current.progress).toBe(0.1) + expect(result.current.progress).toBe(0.08) // Not enough time for a second trickle. act(() => { @@ -287,7 +287,7 @@ test('respects custom incrementDuration', () => { mockRaf.step({ time: 201 }) }) - expect(result.current.progress).toBe(0.1) + expect(result.current.progress).toBe(0.08) // Enough time for the second trickle. act(() => { @@ -295,7 +295,7 @@ test('respects custom incrementDuration', () => { mockRaf.step({ time: 501 }) }) - expect(result.current.progress).toBe(0.2) + expect(result.current.progress).toBe(0.18) unmount() }) @@ -314,13 +314,13 @@ test('keeps its place when minimum changes mid-animation', () => { mockRaf.step({ time: 201 }) }) - expect(result.current.progress).toBe(0.2) + expect(result.current.progress).toBe(0.18) rerender({ minimum: 0.09 }) // A `start` dispatch against a running animation is ignored, so progress - // holds rather than dropping back to increment(0). - expect(result.current.progress).toBe(0.2) + // holds rather than dropping back to the minimum. + expect(result.current.progress).toBe(0.18) unmount() }) @@ -337,7 +337,7 @@ test('keeps trickling when animationDuration changes mid-animation', () => { mockRaf.step({ time: 201 }) }) - expect(result.current.progress).toBe(0.2) + expect(result.current.progress).toBe(0.18) // The animating phase does not read `animationDuration`, so changing it just // before the next trickle is due must not cancel the pending timer. @@ -349,10 +349,7 @@ test('keeps trickling when animationDuration changes mid-animation', () => { mockRaf.step({ time: 401 }) }) - // increment(0.2) is 0.24 in decimal but 0.24000000000000002 in binary - // floating point, so this is the one assertion in the file that cannot use - // an exact match. - expect(result.current.progress).toBeCloseTo(0.24, 10) + expect(result.current.progress).toBe(0.28) unmount() }) From 0652cc259867327ac340c18e039e9b53474189df Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:51:30 +1200 Subject: [PATCH 4/7] Add a configurable increment option Trickle cadence and easing were already configurable, but step size was not, so curves like npm nprogress 0.2.0's random amount of at most 0.02 could not be expressed. `increment` takes the current progress and returns the next value, defaulting to the existing tiered curve. The reducer still clamps the result between `minimum` and `1`, so a custom function cannot move the bar outside the documented range. Any ceiling short of 1 belongs to that function. The trickle timer reads the function through a ref rather than a dependency. Consumers commonly pass an inline function, and depending on its identity would cancel and recreate the timer on every render, stalling the bar whenever renders outpace `incrementDuration`. Co-Authored-By: Claude Opus 5 --- README.md | 39 ++++++++++++---- src/types.ts | 1 + src/useNProgress.tsx | 29 ++++++++++-- test/useNProgress.spec.ts | 98 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 37e231b27..c8bcf034e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Returns the state of one progress bar. Call it once per bar: two calls, or two m ```jsx const { animationDuration, isFinished, progress } = useNProgress({ animationDuration: 300, + increment: (progress) => progress + 0.01, incrementDuration: 500, isAnimating: true, minimum: 0.1, @@ -98,6 +99,7 @@ Takes the options as props and calls `children` with the values the hook returns ```jsx progress + 0.01} incrementDuration={500} isAnimating minimum={0.1} @@ -110,22 +112,41 @@ Takes the options as props and calls `children` with the values the hook returns ### Options -All four options are optional. The type is `NProgressOptions`. +All five options are optional. The type is `NProgressOptions`. -| Option | Type | Default | -| ----------------------------------------- | --------- | ------- | -| [`animationDuration`](#animationduration) | `number` | `200` | -| [`incrementDuration`](#incrementduration) | `number` | `200` | -| [`isAnimating`](#isanimating) | `boolean` | `false` | -| [`minimum`](#minimum) | `number` | `0.08` | +| Option | Type | Default | +| ----------------------------------------- | ------------------------------ | -------------- | +| [`animationDuration`](#animationduration) | `number` | `200` | +| [`increment`](#increment) | `(progress: number) => number` | tiered trickle | +| [`incrementDuration`](#incrementduration) | `number` | `200` | +| [`isAnimating`](#isanimating) | `boolean` | `false` | +| [`minimum`](#minimum) | `number` | `0.08` | #### `animationDuration` Milliseconds the bar is given to animate out once it completes. `progress` reaches `1` as soon as `isAnimating` goes `false`, and `isFinished` follows this many milliseconds later, leaving that window for the exit transition. The value is also returned unchanged, so a single number drives both the timing and the CSS transitions. +#### `increment` + +Size of each trickle step. The function is called with the current `progress` and returns the next value. The default is the tiered curve nprogress uses: `+0.1` below `0.2`, then `+0.04`, `+0.02`, and `+0.005` as `progress` grows, held at a ceiling of `0.994` so the bar never looks complete before it is. + +The return value is clamped to between `minimum` and `1`, and nothing else. A custom function therefore owns its own ceiling. Leave it short of `1`, since reaching `1` is what completion means, and let `isAnimating` going `false` take the bar the rest of the way. + +Returning a random amount is fine, but keep the function free of other side effects. It runs inside a React state update, and StrictMode calls it twice per increment in development. This trickles a random amount of at most `0.02` every 800ms, which is how nprogress 0.2.0 paces itself: + +```jsx +const { progress } = useNProgress({ + increment: (progress) => Math.min(progress + Math.random() * 0.02, 0.994), + incrementDuration: 800, + isAnimating, +}) +``` + +A new function identity on every render is fine too: passing an inline function does not restart the trickle timer. The next increment uses the latest function. + #### `incrementDuration` -Milliseconds between increments while the bar is animating. It controls the trickle pacing only: the size of each increment is not configurable, and shrinks as `progress` grows. +Milliseconds between increments while the bar is animating. It controls the trickle pacing only. Step size is [`increment`](#increment). #### `isAnimating` @@ -143,7 +164,7 @@ Lower bound for `progress`, between `0` and `1`. The bar first appears at this v | ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `animationDuration` | `number` | The `animationDuration` option, passed through so rendering code can transition with it. | | `isFinished` | `boolean` | `true` before the bar starts and again once it has animated out. `false` from when `isAnimating` goes `true` until `animationDuration` after it goes `false`. | -| `progress` | `number` | Starts at `0` and trickles up in shrinking steps to a ceiling of `0.994`, then goes to `1` on completion. | +| `progress` | `number` | Starts at `0`, appears at `minimum` when the bar starts, then trickles up by [`increment`](#increment) and goes to `1` on completion. | ## Live Examples diff --git a/src/types.ts b/src/types.ts index 0df795d07..4cd8b2239 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export interface NProgressOptions { animationDuration?: number + increment?: (progress: number) => number incrementDuration?: number isAnimating?: boolean minimum?: number diff --git a/src/useNProgress.tsx b/src/useNProgress.tsx index 66fc9b99e..eb76fb88b 100644 --- a/src/useNProgress.tsx +++ b/src/useNProgress.tsx @@ -1,8 +1,8 @@ -import { useEffect, useReducer } from 'react' +import { useEffect, useReducer, useRef } from 'react' import { clamp } from './clamp' import { createTimeout } from './createTimeout' -import { increment } from './increment' +import { increment as defaultIncrement } from './increment' import type { NProgressOptions, NProgressState } from './types' // A four-phase state machine. `idle` and `finished` both report @@ -16,8 +16,12 @@ interface State { } type Action = + | { + increment: (progress: number) => number + minimum: number + type: 'trickle' + } | { minimum: number; type: 'start' } - | { minimum: number; type: 'trickle' } | { type: 'complete' } | { type: 'finish' } @@ -59,21 +63,36 @@ const reducer = (state: State, action: Action): State => { } case 'trickle': + // Clamped here rather than trusted from the increment function, so a + // custom one cannot take the bar outside the documented range. Stopping + // short of 1 is that function's own business: 1 is what completion + // means. return { ...state, - progress: clamp(increment(state.progress), action.minimum, 1), + progress: clamp(action.increment(state.progress), action.minimum, 1), } } } export const useNProgress = ({ animationDuration = 200, + increment = defaultIncrement, incrementDuration = 200, isAnimating = false, minimum = 0.08, }: NProgressOptions = {}): NProgressState => { const [{ phase, progress }, dispatch] = useReducer(reducer, initialState) + // Held in a ref so the trickle timer can read the latest increment function + // without listing it as a dependency. Consumers commonly pass an inline + // function, whose identity changes every render; depending on it directly + // would cancel and recreate the timer each time, and a render loop faster + // than `incrementDuration` would stop the bar advancing altogether. + const incrementRef = useRef(increment) + useEffect(() => { + incrementRef.current = increment + }) + useEffect(() => { dispatch(isAnimating ? { minimum, type: 'start' } : { type: 'complete' }) }, [isAnimating, minimum]) @@ -91,7 +110,7 @@ export const useNProgress = ({ const timeout = createTimeout() const trickle = () => { - dispatch({ minimum, type: 'trickle' }) + dispatch({ increment: incrementRef.current, minimum, type: 'trickle' }) timeout.schedule(trickle, incrementDuration) } timeout.schedule(trickle, incrementDuration) diff --git a/test/useNProgress.spec.ts b/test/useNProgress.spec.ts index 8468c2b83..f66d95ed2 100644 --- a/test/useNProgress.spec.ts +++ b/test/useNProgress.spec.ts @@ -354,6 +354,104 @@ test('keeps trickling when animationDuration changes mid-animation', () => { unmount() }) +test('respects a custom increment', () => { + const { result, unmount } = renderHook(() => + useNProgress({ + increment: (progress) => progress + 0.25, + isAnimating: true, + }), + ) + + expect(result.current.progress).toBe(0.08) + + act(() => { + mockRaf.step() + mockRaf.step({ time: 201 }) + }) + + expect(result.current.progress).toBe(0.33) + + unmount() +}) + +test('clamps a custom increment to the documented range', () => { + const { result, rerender, unmount } = renderHook( + ({ increment }) => useNProgress({ increment, isAnimating: true }), + { initialProps: { increment: () => 5 } }, + ) + + act(() => { + mockRaf.step() + mockRaf.step({ time: 201 }) + }) + + expect(result.current.progress).toBe(1) + + rerender({ increment: () => -5 }) + + act(() => { + mockRaf.step() + mockRaf.step({ time: 401 }) + }) + + expect(result.current.progress).toBe(0.08) + + unmount() +}) + +test('uses the latest increment on the next trickle', () => { + const { result, rerender, unmount } = renderHook( + ({ increment }) => useNProgress({ increment, isAnimating: true }), + { initialProps: { increment: (progress: number) => progress + 0.1 } }, + ) + + act(() => { + mockRaf.step() + mockRaf.step({ time: 201 }) + }) + + expect(result.current.progress).toBe(0.18) + + rerender({ increment: (progress: number) => progress + 0.25 }) + + act(() => { + mockRaf.step() + mockRaf.step({ time: 401 }) + }) + + expect(result.current.progress).toBe(0.43) + + unmount() +}) + +// Consumers commonly pass an inline function, so a new identity arrives on +// every render. That must not cancel the pending trickle timer. +test('keeps trickling when a new increment identity arrives mid-animation', () => { + const { result, rerender, unmount } = renderHook( + ({ increment }) => useNProgress({ increment, isAnimating: true }), + { initialProps: { increment: (progress: number) => progress + 0.1 } }, + ) + + act(() => { + mockRaf.step() + mockRaf.step({ time: 201 }) + }) + + expect(result.current.progress).toBe(0.18) + + act(() => { + mockRaf.step({ time: 399 }) + }) + rerender({ increment: (progress: number) => progress + 0.1 }) + act(() => { + mockRaf.step({ time: 401 }) + }) + + expect(result.current.progress).toBe(0.28) + + unmount() +}) + test('applies a new animationDuration to the completion it owns', () => { const { result, rerender, unmount } = renderHook( ({ animationDuration, isAnimating }) => From 6861746939aea52f0041b1e99476ec8a71e0f652 Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:56:22 +1200 Subject: [PATCH 5/7] Note which nprogress variant we mirror The npm 0.2.0 release and the nprogress master branch trickle differently, and the official demo page loads 0.2.0. Say in the background section that this library follows master, and point at the increment option for the older pacing. Note the easing difference alongside the 0.2.0 increment example, since easing is the renderer's CSS rather than an option here. Co-Authored-By: Claude Opus 5 --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c8bcf034e..867f555fb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ This is a React port of [rstacruz](https://github.com/rstacruz)'s [`nprogress`](https://github.com/rstacruz/nprogress) module. It exposes an API that encapsulates the logic of `nprogress` and renders nothing, allowing consumers to implement their own rendering. +Two versions of `nprogress` are in circulation and they trickle differently. The 2014 npm release, `0.2.0`, adds a random amount of at most `0.02` every 800ms. The repository's master branch, never published to npm, adds tiered amounts every 200ms. This library mirrors master, so a side by side comparison against the npm package or the official demo page will show a different pace. The [`increment`](#increment) option covers the `0.2.0` pacing if you prefer the older feel. + ## When to Use This This package is a headless primitive. It renders no markup and ships no CSS, supplying only the pacing state: a `progress` value that trickles towards completion, an `isFinished` flag, and the `animationDuration` to transition with. The bar itself is yours to write. @@ -132,7 +134,7 @@ Size of each trickle step. The function is called with the current `progress` an The return value is clamped to between `minimum` and `1`, and nothing else. A custom function therefore owns its own ceiling. Leave it short of `1`, since reaching `1` is what completion means, and let `isAnimating` going `false` take the bar the rest of the way. -Returning a random amount is fine, but keep the function free of other side effects. It runs inside a React state update, and StrictMode calls it twice per increment in development. This trickles a random amount of at most `0.02` every 800ms, which is how nprogress 0.2.0 paces itself: +Returning a random amount is fine, but keep the function free of other side effects. It runs inside a React state update, and StrictMode calls it twice per increment in development. This trickles a random amount of at most `0.02` every 800ms, which is how nprogress `0.2.0` paces itself: ```jsx const { progress } = useNProgress({ @@ -142,6 +144,8 @@ const { progress } = useNProgress({ }) ``` +`0.2.0` also transitions the bar with `ease` where master uses `linear`. Easing lives in your renderer's CSS, so match it there if you want the rest of that look. + A new function identity on every render is fine too: passing an inline function does not restart the trickle timer. The next increment uses the latest function. #### `incrementDuration` From d2bac673c407c24e59989a707718a3ea4f4e32fe Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:11:43 +1200 Subject: [PATCH 6/7] Document keying the bar on restart `progress` returns to `minimum` when the bar starts again, so a bar left mounted between runs transitions backwards from where it finished, in full view. Every example already avoids this by changing a key on the progress component at each start, but only the react-router example said why, and the README usage snippets do not show the pattern at all. Add a "Restarting" note to the README covering the behaviour and the keying pattern, and a comment at each key flip in the examples. Dropping the transition while `isFinished` looks like an alternative but is not: the commit that resets `progress` also flips `isFinished` to `false`, so the transition is live for the step that moves the bar. --- README.md | 18 ++++++++++++++++++ examples/material-ui/src/main.tsx | 3 +++ examples/multiple-instances/src/main.tsx | 6 ++++++ .../components/NavigationProgress.tsx | 3 +++ examples/next-pages-router/pages/_app.tsx | 3 +++ examples/original-design/src/main.tsx | 3 +++ examples/plain-js/src/main.jsx | 3 +++ examples/react-router/src/main.tsx | 3 +++ examples/render-props/src/main.tsx | 3 +++ 9 files changed, 45 insertions(+) diff --git a/README.md b/README.md index 867f555fb..6b52e9ba4 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,24 @@ const Progress = ({ isAnimating }) => ( ) ``` +**Restarting** + +Both patterns leave the bar mounted between runs, and `progress` returns to `minimum` when it starts again. A bar that transitions `margin-left` or `transform` therefore animates backwards from where it finished, in full view, before it starts trickling forward. Back to back navigations hit this every time. + +Change a `key` on the bar whenever it starts, so React mounts a fresh element at `minimum` instead: + +```jsx +const [state, setState] = useState({ isAnimating: false, key: 0 }) + +const start = () => { + setState((prevState) => ({ isAnimating: true, key: prevState.key ^ 1 })) +} + +return +``` + +Every entry in [Live Examples](#live-examples) does this. Dropping the transition while `isFinished` is not an alternative: `isFinished` is already `false` by the time `progress` resets, so the transition is back on for the step that moves the bar. + ## API The package exports one hook and one component. Both take the same [options](#options) and produce the same [values](#return-value), so the choice between them is a matter of which pattern suits the calling code. Both shapes are exported as types, for typing code that wraps either entry point: diff --git a/examples/material-ui/src/main.tsx b/examples/material-ui/src/main.tsx index b32844bd7..5fda390d9 100644 --- a/examples/material-ui/src/main.tsx +++ b/examples/material-ui/src/main.tsx @@ -34,6 +34,9 @@ const App = () => { onClick={() => { setState((prevState) => ({ isAnimating: !prevState.isAnimating, + // A new key on each start remounts the bar, so it re-enters + // from the left rather than animating backwards from where the + // last run finished. key: prevState.isAnimating ? prevState.key : prevState.key ^ 1, })) }} diff --git a/examples/multiple-instances/src/main.tsx b/examples/multiple-instances/src/main.tsx index 68fb4a19a..da48e51c8 100644 --- a/examples/multiple-instances/src/main.tsx +++ b/examples/multiple-instances/src/main.tsx @@ -22,6 +22,9 @@ const A = () => { onClick={() => { setState((prevState) => ({ isAnimating: !prevState.isAnimating, + // A new key on each start remounts the bar, so it re-enters from + // the left rather than animating backwards from where the last + // run finished. key: prevState.isAnimating ? prevState.key : prevState.key ^ 1, })) }} @@ -49,6 +52,9 @@ const B = () => { onClick={() => { setState((prevState) => ({ isAnimating: !prevState.isAnimating, + // A new key on each start remounts the bar, so it re-enters from + // the left rather than animating backwards from where the last + // run finished. key: prevState.isAnimating ? prevState.key : prevState.key ^ 1, })) }} diff --git a/examples/next-app-router/components/NavigationProgress.tsx b/examples/next-app-router/components/NavigationProgress.tsx index 700260454..1ab2a566e 100644 --- a/examples/next-app-router/components/NavigationProgress.tsx +++ b/examples/next-app-router/components/NavigationProgress.tsx @@ -64,6 +64,9 @@ export default function NavigationProgress({ const contextValue = useRef({ start: () => { setIsRouteChanging(true) + // A new key on each start remounts the bar, so it re-enters from the + // left rather than animating backwards from where the last navigation + // finished. setLoadingKey((prev) => prev ^ 1) }, }).current diff --git a/examples/next-pages-router/pages/_app.tsx b/examples/next-pages-router/pages/_app.tsx index 3796aa4a3..5867f7161 100644 --- a/examples/next-pages-router/pages/_app.tsx +++ b/examples/next-pages-router/pages/_app.tsx @@ -18,6 +18,9 @@ const App: React.FC = ({ Component, pageProps }) => { setState((prevState) => ({ ...prevState, isRouteChanging: true, + // A new key on each start remounts the bar, so it re-enters from the + // left rather than animating backwards from where the last navigation + // finished. loadingKey: prevState.loadingKey ^ 1, })) } diff --git a/examples/original-design/src/main.tsx b/examples/original-design/src/main.tsx index 266b4f472..e261dd8e0 100644 --- a/examples/original-design/src/main.tsx +++ b/examples/original-design/src/main.tsx @@ -18,6 +18,9 @@ const App = () => { onClick={() => { setState((prevState) => ({ isAnimating: !prevState.isAnimating, + // A new key on each start remounts the bar, so it re-enters from + // the left rather than animating backwards from where the last + // run finished. key: prevState.isAnimating ? prevState.key : prevState.key ^ 1, })) }} diff --git a/examples/plain-js/src/main.jsx b/examples/plain-js/src/main.jsx index 273d2186f..1baf3d778 100644 --- a/examples/plain-js/src/main.jsx +++ b/examples/plain-js/src/main.jsx @@ -18,6 +18,9 @@ const App = () => { onClick={() => { setState((prevState) => ({ isAnimating: !prevState.isAnimating, + // A new key on each start remounts the bar, so it re-enters from + // the left rather than animating backwards from where the last + // run finished. key: prevState.isAnimating ? prevState.key : prevState.key ^ 1, })) }} diff --git a/examples/react-router/src/main.tsx b/examples/react-router/src/main.tsx index 24d8a3d02..b07e35ffb 100644 --- a/examples/react-router/src/main.tsx +++ b/examples/react-router/src/main.tsx @@ -169,6 +169,9 @@ const Home = () => { A key change creates a new NProgress instance, resetting progress when the location changes. See: https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key. + Remounting is also what makes the bar re-enter from the left on the + next navigation, rather than animating backwards from where the last + one finished. */}
diff --git a/examples/render-props/src/main.tsx b/examples/render-props/src/main.tsx index 266b4f472..e261dd8e0 100644 --- a/examples/render-props/src/main.tsx +++ b/examples/render-props/src/main.tsx @@ -18,6 +18,9 @@ const App = () => { onClick={() => { setState((prevState) => ({ isAnimating: !prevState.isAnimating, + // A new key on each start remounts the bar, so it re-enters from + // the left rather than animating backwards from where the last + // run finished. key: prevState.isAnimating ? prevState.key : prevState.key ^ 1, })) }} From 0f3675660d1fc1719cba2bbde1392afd26116351 Mon Sep 17 00:00:00 2001 From: Tane Morgan <464864+tanem@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:27:48 +1200 Subject: [PATCH 7/7] Add a classic nprogress 0.2.0 example The npm 0.2.0 release is what the nprogress demo page loads, and its pacing is the best-known one: a random amount of at most 0.02 every 800ms, eased. The defaults here follow the master branch instead, so the `increment` option is what closes the gap. Configuration mirrors 0.2.0's trickleRate, trickleSpeed and easing. The remaining settings already match: minimum 0.08, and speed 200, which is animationDuration. The bar and spinner styling is the original-design example's, so the only visible difference between the two is pacing. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- examples/classic-020/.codesandbox/tasks.json | 32 +++++++++++++++ .../.devcontainer/devcontainer.json | 4 ++ examples/classic-020/.gitignore | 2 + examples/classic-020/README.md | 39 +++++++++++++++++++ examples/classic-020/index.html | 12 ++++++ examples/classic-020/package.json | 28 +++++++++++++ examples/classic-020/src/Bar.tsx | 37 ++++++++++++++++++ examples/classic-020/src/Container.tsx | 20 ++++++++++ examples/classic-020/src/Progress.tsx | 29 ++++++++++++++ examples/classic-020/src/Spinner.tsx | 29 ++++++++++++++ examples/classic-020/src/index.css | 8 ++++ examples/classic-020/src/main.tsx | 36 +++++++++++++++++ examples/classic-020/src/vite-env.d.ts | 1 + examples/classic-020/tsconfig.json | 18 +++++++++ examples/classic-020/vite.config.ts | 6 +++ 16 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 examples/classic-020/.codesandbox/tasks.json create mode 100644 examples/classic-020/.devcontainer/devcontainer.json create mode 100644 examples/classic-020/.gitignore create mode 100644 examples/classic-020/README.md create mode 100644 examples/classic-020/index.html create mode 100644 examples/classic-020/package.json create mode 100644 examples/classic-020/src/Bar.tsx create mode 100644 examples/classic-020/src/Container.tsx create mode 100644 examples/classic-020/src/Progress.tsx create mode 100644 examples/classic-020/src/Spinner.tsx create mode 100644 examples/classic-020/src/index.css create mode 100644 examples/classic-020/src/main.tsx create mode 100644 examples/classic-020/src/vite-env.d.ts create mode 100644 examples/classic-020/tsconfig.json create mode 100644 examples/classic-020/vite.config.ts diff --git a/README.md b/README.md index 6b52e9ba4..96fd69768 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ const { progress } = useNProgress({ }) ``` -`0.2.0` also transitions the bar with `ease` where master uses `linear`. Easing lives in your renderer's CSS, so match it there if you want the rest of that look. +`0.2.0` also transitions the bar with `ease` where master uses `linear`. Easing lives in your renderer's CSS, so match it there if you want the rest of that look. The [Classic 0.2.0](https://github.com/tanem/react-nprogress/tree/master/examples/classic-020) example puts both together. A new function identity on every render is fine too: passing an inline function does not restart the trickle timer. The next increment uses the latest function. @@ -192,6 +192,7 @@ Lower bound for `progress`, between `0` and `1`. The bar first appears at this v | Example | Sandbox | | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| [Classic 0.2.0](https://github.com/tanem/react-nprogress/tree/master/examples/classic-020) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/classic-020) | | [Material UI](https://github.com/tanem/react-nprogress/tree/master/examples/material-ui) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/material-ui) | | [Multiple Instances](https://github.com/tanem/react-nprogress/tree/master/examples/multiple-instances) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/multiple-instances) | | [Next App Router](https://github.com/tanem/react-nprogress/tree/master/examples/next-app-router) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/next-app-router) | diff --git a/examples/classic-020/.codesandbox/tasks.json b/examples/classic-020/.codesandbox/tasks.json new file mode 100644 index 000000000..4c28486ec --- /dev/null +++ b/examples/classic-020/.codesandbox/tasks.json @@ -0,0 +1,32 @@ +{ + "setupTasks": [ + { + "name": "Install Dependencies", + "command": "pnpm install" + } + ], + "tasks": { + "dev": { + "name": "dev", + "command": "pnpm dev", + "runAtStart": true, + "preview": { + "port": 5173 + } + }, + "build": { + "name": "build", + "command": "pnpm build", + "runAtStart": false + }, + "preview": { + "name": "preview", + "command": "pnpm preview", + "runAtStart": false + }, + "install": { + "name": "install dependencies", + "command": "pnpm install" + } + } +} diff --git a/examples/classic-020/.devcontainer/devcontainer.json b/examples/classic-020/.devcontainer/devcontainer.json new file mode 100644 index 000000000..3139d4259 --- /dev/null +++ b/examples/classic-020/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Devcontainer", + "image": "ghcr.io/codesandbox/devcontainers/typescript-node:latest" +} diff --git a/examples/classic-020/.gitignore b/examples/classic-020/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/examples/classic-020/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/classic-020/README.md b/examples/classic-020/README.md new file mode 100644 index 000000000..a21cbf837 --- /dev/null +++ b/examples/classic-020/README.md @@ -0,0 +1,39 @@ +# ReactNProgress Classic 0.2.0 Example + +Reproduces the pacing of nprogress `0.2.0`, the npm release the nprogress demo +page loads: the bar appears near the minimum and creeps up in small eased steps +every 800ms. + +The library defaults follow the nprogress master branch instead, which trickles +tiered amounts every 200ms with `linear` easing. The [Original +Design](../original-design) example shows that. This one changes three things +to get back to `0.2.0`. + +| nprogress `0.2.0` setting | Here | +| ------------------------- | ------------------------------------------------------------- | +| `trickleRate: 0.02` | `increment: (p) => Math.min(p + Math.random() * 0.02, 0.994)` | +| `trickleSpeed: 800` | `incrementDuration: 800` | +| `easing: 'ease'` | the bar's CSS `transition` in `src/Bar.tsx` | + +The remaining `0.2.0` settings already match the defaults: `minimum: 0.08`, and +`speed: 200`, which is `animationDuration`. The `0.994` ceiling is part of the +increment function here, because the option's return value is only clamped to +between `minimum` and `1`. + +Easing is not an option: this package renders nothing, so transitions live in +your own CSS. Only the bar position is eased in `0.2.0`. The fade-out stays +`linear`. + +## Available Scripts + +### `npm run dev` + +Runs the app in development mode. + +### `npm run build` + +Builds the app for production. + +### `npm run preview` + +Previews the production build locally. diff --git a/examples/classic-020/index.html b/examples/classic-020/index.html new file mode 100644 index 000000000..281e019df --- /dev/null +++ b/examples/classic-020/index.html @@ -0,0 +1,12 @@ + + + + + + ReactNProgress Classic 0.2.0 Example + + +
+ + + diff --git a/examples/classic-020/package.json b/examples/classic-020/package.json new file mode 100644 index 000000000..3ebdfad04 --- /dev/null +++ b/examples/classic-020/package.json @@ -0,0 +1,28 @@ +{ + "name": "classic-020", + "description": "ReactNProgress Classic 0.2.0 Example", + "keywords": [ + "@tanem/react-nprogress" + ], + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@tanem/react-nprogress": "latest", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.3" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "start": "vite" + } +} diff --git a/examples/classic-020/src/Bar.tsx b/examples/classic-020/src/Bar.tsx new file mode 100644 index 000000000..6b2e370d6 --- /dev/null +++ b/examples/classic-020/src/Bar.tsx @@ -0,0 +1,37 @@ +import type { FC } from 'react' + +const Bar: FC<{ animationDuration: number; progress: number }> = ({ + animationDuration, + progress, +}) => ( +
+
+
+) + +export default Bar diff --git a/examples/classic-020/src/Container.tsx b/examples/classic-020/src/Container.tsx new file mode 100644 index 000000000..17d4bb326 --- /dev/null +++ b/examples/classic-020/src/Container.tsx @@ -0,0 +1,20 @@ +import type { FC, PropsWithChildren } from 'react' + +const Container: FC< + PropsWithChildren<{ + animationDuration: number + isFinished: boolean + }> +> = ({ animationDuration, children, isFinished }) => ( +
+ {children} +
+) + +export default Container diff --git a/examples/classic-020/src/Progress.tsx b/examples/classic-020/src/Progress.tsx new file mode 100644 index 000000000..e690595f0 --- /dev/null +++ b/examples/classic-020/src/Progress.tsx @@ -0,0 +1,29 @@ +import { useNProgress } from '@tanem/react-nprogress' +import type { FC } from 'react' + +import Bar from './Bar' +import Container from './Container' +import Spinner from './Spinner' + +// nprogress 0.2.0 trickles by `Math.random() * trickleRate`, then clamps the +// result to 0.994 so the bar never looks complete before it is. +const trickle = (progress: number) => + Math.min(progress + Math.random() * 0.02, 0.994) + +const Progress: FC<{ isAnimating: boolean }> = ({ isAnimating }) => { + const { animationDuration, isFinished, progress } = useNProgress({ + increment: trickle, + // 0.2.0's trickleSpeed. The default of 200 is the master branch's. + incrementDuration: 800, + isAnimating, + }) + + return ( + + + + + ) +} + +export default Progress diff --git a/examples/classic-020/src/Spinner.tsx b/examples/classic-020/src/Spinner.tsx new file mode 100644 index 000000000..18dddacb9 --- /dev/null +++ b/examples/classic-020/src/Spinner.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react' + +const Spinner: FC = () => ( +
+
+
+) + +export default Spinner diff --git a/examples/classic-020/src/index.css b/examples/classic-020/src/index.css new file mode 100644 index 000000000..89db1e016 --- /dev/null +++ b/examples/classic-020/src/index.css @@ -0,0 +1,8 @@ +@keyframes spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/examples/classic-020/src/main.tsx b/examples/classic-020/src/main.tsx new file mode 100644 index 000000000..e261dd8e0 --- /dev/null +++ b/examples/classic-020/src/main.tsx @@ -0,0 +1,36 @@ +import './index.css' + +import { useState } from 'react' +import { createRoot } from 'react-dom/client' + +import Progress from './Progress' + +const App = () => { + const [state, setState] = useState({ + isAnimating: false, + key: 0, + }) + + return ( + <> + + + + ) +} + +const container = document.getElementById('root') +const root = createRoot(container!) +root.render() diff --git a/examples/classic-020/src/vite-env.d.ts b/examples/classic-020/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/examples/classic-020/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/classic-020/tsconfig.json b/examples/classic-020/tsconfig.json new file mode 100644 index 000000000..afb9ce332 --- /dev/null +++ b/examples/classic-020/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/examples/classic-020/vite.config.ts b/examples/classic-020/vite.config.ts new file mode 100644 index 000000000..ae745180e --- /dev/null +++ b/examples/classic-020/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], +})