Skip to content

Commit 329f784

Browse files
authored
Merge pull request #3025 from tanem/nprogress-fidelity
Match nprogress timing more closely
2 parents 4304eb3 + 0f36756 commit 329f784

30 files changed

Lines changed: 531 additions & 46 deletions

File tree

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.next
12
README.md
23
coverage
34
dist

README.md

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
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.
1616

17+
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.
18+
1719
## When to Use This
1820

1921
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.
@@ -70,6 +72,24 @@ const Progress = ({ isAnimating }) => (
7072
)
7173
```
7274

75+
**Restarting**
76+
77+
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.
78+
79+
Change a `key` on the bar whenever it starts, so React mounts a fresh element at `minimum` instead:
80+
81+
```jsx
82+
const [state, setState] = useState({ isAnimating: false, key: 0 })
83+
84+
const start = () => {
85+
setState((prevState) => ({ isAnimating: true, key: prevState.key ^ 1 }))
86+
}
87+
88+
return <Progress isAnimating={state.isAnimating} key={state.key} />
89+
```
90+
91+
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.
92+
7393
## API
7494

7595
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:
@@ -85,6 +105,7 @@ Returns the state of one progress bar. Call it once per bar: two calls, or two m
85105
```jsx
86106
const { animationDuration, isFinished, progress } = useNProgress({
87107
animationDuration: 300,
108+
increment: (progress) => progress + 0.01,
88109
incrementDuration: 500,
89110
isAnimating: true,
90111
minimum: 0.1,
@@ -98,6 +119,7 @@ Takes the options as props and calls `children` with the values the hook returns
98119
```jsx
99120
<NProgress
100121
animationDuration={300}
122+
increment={(progress) => progress + 0.01}
101123
incrementDuration={500}
102124
isAnimating
103125
minimum={0.1}
@@ -110,30 +132,51 @@ Takes the options as props and calls `children` with the values the hook returns
110132

111133
### Options
112134

113-
All four options are optional. The type is `NProgressOptions`.
135+
All five options are optional. The type is `NProgressOptions`.
114136

115-
| Option | Type | Default |
116-
| ----------------------------------------- | --------- | ------- |
117-
| [`animationDuration`](#animationduration) | `number` | `200` |
118-
| [`incrementDuration`](#incrementduration) | `number` | `200` |
119-
| [`isAnimating`](#isanimating) | `boolean` | `false` |
120-
| [`minimum`](#minimum) | `number` | `0.08` |
137+
| Option | Type | Default |
138+
| ----------------------------------------- | ------------------------------ | -------------- |
139+
| [`animationDuration`](#animationduration) | `number` | `200` |
140+
| [`increment`](#increment) | `(progress: number) => number` | tiered trickle |
141+
| [`incrementDuration`](#incrementduration) | `number` | `200` |
142+
| [`isAnimating`](#isanimating) | `boolean` | `false` |
143+
| [`minimum`](#minimum) | `number` | `0.08` |
121144

122145
#### `animationDuration`
123146

124147
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.
125148

149+
#### `increment`
150+
151+
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.
152+
153+
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.
154+
155+
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:
156+
157+
```jsx
158+
const { progress } = useNProgress({
159+
increment: (progress) => Math.min(progress + Math.random() * 0.02, 0.994),
160+
incrementDuration: 800,
161+
isAnimating,
162+
})
163+
```
164+
165+
`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.
166+
167+
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.
168+
126169
#### `incrementDuration`
127170

128-
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.
171+
Milliseconds between increments while the bar is animating. It controls the trickle pacing only. Step size is [`increment`](#increment).
129172

130173
#### `isAnimating`
131174

132175
Whether the bar is running. Going `true` starts it, going `false` completes it. Completion is what drives the final state: `progress` is set to `1`, and `isFinished` becomes `true` `animationDuration` milliseconds later.
133176

134177
#### `minimum`
135178

136-
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.
179+
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.
137180

138181
### Return Value
139182

@@ -143,12 +186,13 @@ Lower bound for `progress`, between `0` and `1`. The first increment starts from
143186
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144187
| `animationDuration` | `number` | The `animationDuration` option, passed through so rendering code can transition with it. |
145188
| `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`. |
146-
| `progress` | `number` | Starts at `0` and trickles up in shrinking steps to a ceiling of `0.994`, then goes to `1` on completion. |
189+
| `progress` | `number` | Starts at `0`, appears at `minimum` when the bar starts, then trickles up by [`increment`](#increment) and goes to `1` on completion. |
147190

148191
## Live Examples
149192

150193
| Example | Sandbox |
151194
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
195+
| [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) |
152196
| [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) |
153197
| [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) |
154198
| [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) |

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import tseslint from 'typescript-eslint'
99

1010
export default tseslint.config(
1111
{
12-
ignores: ['**/coverage/', '**/dist/', '**/node_modules/'],
12+
ignores: ['**/.next/', '**/coverage/', '**/dist/', '**/node_modules/'],
1313
},
1414
js.configs.recommended,
1515
...tseslint.configs.recommended,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"setupTasks": [
3+
{
4+
"name": "Install Dependencies",
5+
"command": "pnpm install"
6+
}
7+
],
8+
"tasks": {
9+
"dev": {
10+
"name": "dev",
11+
"command": "pnpm dev",
12+
"runAtStart": true,
13+
"preview": {
14+
"port": 5173
15+
}
16+
},
17+
"build": {
18+
"name": "build",
19+
"command": "pnpm build",
20+
"runAtStart": false
21+
},
22+
"preview": {
23+
"name": "preview",
24+
"command": "pnpm preview",
25+
"runAtStart": false
26+
},
27+
"install": {
28+
"name": "install dependencies",
29+
"command": "pnpm install"
30+
}
31+
}
32+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "Devcontainer",
3+
"image": "ghcr.io/codesandbox/devcontainers/typescript-node:latest"
4+
}

examples/classic-020/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
dist

examples/classic-020/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# ReactNProgress Classic 0.2.0 Example
2+
3+
Reproduces the pacing of nprogress `0.2.0`, the npm release the nprogress demo
4+
page loads: the bar appears near the minimum and creeps up in small eased steps
5+
every 800ms.
6+
7+
The library defaults follow the nprogress master branch instead, which trickles
8+
tiered amounts every 200ms with `linear` easing. The [Original
9+
Design](../original-design) example shows that. This one changes three things
10+
to get back to `0.2.0`.
11+
12+
| nprogress `0.2.0` setting | Here |
13+
| ------------------------- | ------------------------------------------------------------- |
14+
| `trickleRate: 0.02` | `increment: (p) => Math.min(p + Math.random() * 0.02, 0.994)` |
15+
| `trickleSpeed: 800` | `incrementDuration: 800` |
16+
| `easing: 'ease'` | the bar's CSS `transition` in `src/Bar.tsx` |
17+
18+
The remaining `0.2.0` settings already match the defaults: `minimum: 0.08`, and
19+
`speed: 200`, which is `animationDuration`. The `0.994` ceiling is part of the
20+
increment function here, because the option's return value is only clamped to
21+
between `minimum` and `1`.
22+
23+
Easing is not an option: this package renders nothing, so transitions live in
24+
your own CSS. Only the bar position is eased in `0.2.0`. The fade-out stays
25+
`linear`.
26+
27+
## Available Scripts
28+
29+
### `npm run dev`
30+
31+
Runs the app in development mode.
32+
33+
### `npm run build`
34+
35+
Builds the app for production.
36+
37+
### `npm run preview`
38+
39+
Previews the production build locally.

examples/classic-020/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>ReactNProgress Classic 0.2.0 Example</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

examples/classic-020/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "classic-020",
3+
"description": "ReactNProgress Classic 0.2.0 Example",
4+
"keywords": [
5+
"@tanem/react-nprogress"
6+
],
7+
"version": "0.1.0",
8+
"private": true,
9+
"type": "module",
10+
"dependencies": {
11+
"@tanem/react-nprogress": "latest",
12+
"react": "19.2.4",
13+
"react-dom": "19.2.4"
14+
},
15+
"devDependencies": {
16+
"@types/react": "^19.0.0",
17+
"@types/react-dom": "^19.0.0",
18+
"@vitejs/plugin-react": "^4.3.4",
19+
"typescript": "^5.7.2",
20+
"vite": "^6.0.3"
21+
},
22+
"scripts": {
23+
"dev": "vite",
24+
"build": "tsc && vite build",
25+
"preview": "vite preview",
26+
"start": "vite"
27+
}
28+
}

examples/classic-020/src/Bar.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { FC } from 'react'
2+
3+
const Bar: FC<{ animationDuration: number; progress: number }> = ({
4+
animationDuration,
5+
progress,
6+
}) => (
7+
<div
8+
style={{
9+
background: '#29d',
10+
height: 2,
11+
left: 0,
12+
marginLeft: `${(-1 + progress) * 100}%`,
13+
position: 'fixed',
14+
top: 0,
15+
// 0.2.0's easing setting. The master branch uses `linear`, which is what
16+
// the Original Design example shows.
17+
transition: `margin-left ${animationDuration}ms ease`,
18+
width: '100%',
19+
zIndex: 1031,
20+
}}
21+
>
22+
<div
23+
style={{
24+
boxShadow: '0 0 10px #29d, 0 0 5px #29d',
25+
display: 'block',
26+
height: '100%',
27+
opacity: 1,
28+
position: 'absolute',
29+
right: 0,
30+
transform: 'rotate(3deg) translate(0px, -4px)',
31+
width: 100,
32+
}}
33+
/>
34+
</div>
35+
)
36+
37+
export default Bar

0 commit comments

Comments
 (0)