Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.next
README.md
coverage
dist
Expand Down
64 changes: 54 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -70,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 <Progress isAnimating={state.isAnimating} key={state.key} />
```

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:
Expand All @@ -85,6 +105,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,
Expand All @@ -98,6 +119,7 @@ Takes the options as props and calls `children` with the values the hook returns
```jsx
<NProgress
animationDuration={300}
increment={(progress) => progress + 0.01}
incrementDuration={500}
isAnimating
minimum={0.1}
Expand All @@ -110,30 +132,51 @@ 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,
})
```

`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.

#### `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`

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.

#### `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

Expand All @@ -143,12 +186,13 @@ Lower bound for `progress`, between `0` and `1`. The first increment starts from
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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

| 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) |
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions examples/classic-020/.codesandbox/tasks.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
4 changes: 4 additions & 0 deletions examples/classic-020/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Devcontainer",
"image": "ghcr.io/codesandbox/devcontainers/typescript-node:latest"
}
2 changes: 2 additions & 0 deletions examples/classic-020/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
39 changes: 39 additions & 0 deletions examples/classic-020/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions examples/classic-020/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ReactNProgress Classic 0.2.0 Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
28 changes: 28 additions & 0 deletions examples/classic-020/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
37 changes: 37 additions & 0 deletions examples/classic-020/src/Bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { FC } from 'react'

const Bar: FC<{ animationDuration: number; progress: number }> = ({
animationDuration,
progress,
}) => (
<div
style={{
background: '#29d',
height: 2,
left: 0,
marginLeft: `${(-1 + progress) * 100}%`,
position: 'fixed',
top: 0,
// 0.2.0's easing setting. The master branch uses `linear`, which is what
// the Original Design example shows.
transition: `margin-left ${animationDuration}ms ease`,
width: '100%',
zIndex: 1031,
}}
>
<div
style={{
boxShadow: '0 0 10px #29d, 0 0 5px #29d',
display: 'block',
height: '100%',
opacity: 1,
position: 'absolute',
right: 0,
transform: 'rotate(3deg) translate(0px, -4px)',
width: 100,
}}
/>
</div>
)

export default Bar
20 changes: 20 additions & 0 deletions examples/classic-020/src/Container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { FC, PropsWithChildren } from 'react'

const Container: FC<
PropsWithChildren<{
animationDuration: number
isFinished: boolean
}>
> = ({ animationDuration, children, isFinished }) => (
<div
style={{
opacity: isFinished ? 0 : 1,
pointerEvents: 'none',
transition: `opacity ${animationDuration}ms linear`,
}}
>
{children}
</div>
)

export default Container
29 changes: 29 additions & 0 deletions examples/classic-020/src/Progress.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container animationDuration={animationDuration} isFinished={isFinished}>
<Bar animationDuration={animationDuration} progress={progress} />
<Spinner />
</Container>
)
}

export default Progress
29 changes: 29 additions & 0 deletions examples/classic-020/src/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { FC } from 'react'

const Spinner: FC = () => (
<div
style={{
display: 'block',
position: 'fixed',
right: 15,
top: 15,
zIndex: 1031,
}}
>
<div
style={{
animation: '400ms linear infinite spinner',
borderBottom: '2px solid transparent',
borderLeft: '2px solid #29d',
borderRadius: '50%',
borderRight: '2px solid transparent',
borderTop: '2px solid #29d',
boxSizing: 'border-box',
height: 18,
width: 18,
}}
/>
</div>
)

export default Spinner
8 changes: 8 additions & 0 deletions examples/classic-020/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
Loading
Loading