---
title: Boot Loader
description: A count to a hundred that stalls at ninety until the thing it is covering has actually arrived.
source: https://adamjurek.com/components/boot-loader
---

## Home


A machine booting: a mark, a number, and a rule.

<!-- ::start:showcase demo="boot-loader" height="340" -->
<!-- ::end:showcase -->

## The number is not a measurement, and that is the point

Nothing on this page can report real progress. A GLB has either arrived or it
has not; a font either is or is not. A number derived from bytes would jump
from 0 to 100 with nothing in between, which is worse than no number.

So this eases to **90 on a timer**, waits there for `ready`, then runs to 100.

That makes it honest in a different way. The number is a promise about
*attention* rather than about bytes - it says something is happening and
roughly how long is left - and it can never claim to be finished while the
thing it covers has not arrived.

```tsx
<BootLoader ready={modelLoaded} onDone={reveal}>
	<SpinningMark />
</BootLoader>
```

| Without `ready` | With it |
| --- | --- |
| Finishes on schedule whether or not anything loaded | Cannot reach 100 until the work reports in |
| The reveal shows an empty canvas | The reveal shows the thing |

> [!NOTE] Ninety, not ninety-nine
> A counter parked on 99 reads as stuck. One at 90 reads as nearly there, and
> the last tenth is where the eye expects a pause anyway.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/boot-loader.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/boot-loader.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | motion · Scroll effects |
| Files | `boot-loader.tsx` |
| Dependencies | None |
| Tags | block, loading, raf, a11y, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { useState } from "react";
import { BootLoader } from "@sushindustries/ui";

export function Example() {
	const [ready, setReady] = useState(false);

	return (
		<div className="device-screen" style={{ position: "relative" }}>
			<BootLoader ready={ready} onDone={() => console.log("revealed")}>
				<SpinningMark />
			</BootLoader>
		</div>
	);
}
```

## What you should see

A number climbing from 000, easing quickly at first and slowing as it
nears 90 - it stalls there and waits. Once `ready` flips to `true` it
finishes the run to 100, holds for a beat, then the whole component
unmounts and `onDone` fires. The rail underneath tracks the same number
as a length, not a second animation.

## If nothing happens

If the counter never appears, check the parent has `position: relative` -
`BootLoader` is `position: absolute; inset: 0` and needs a positioned
ancestor to fill, which is why it pairs with `.device-screen` rather than
the page body. If it reaches 90 and never finishes, `ready` never flipped
to `true` - that is not a bug, it is the component refusing to claim work
is done that has not arrived.


## Guides


## It fills its parent, never the viewport

On this site it boots a *screen* - the desktop inside a device, not the page
around it.

A loader that covered the browser window would hide the article somebody is
already reading in order to announce that a decoration further down is not
ready. `position: absolute; inset: 0` puts it inside whatever box you give it,
which needs a positioned ancestor and gets one from `.device-screen`.

<!-- ::start:spacer size="6" rule="true" -->
<!-- ::end:spacer -->

## Three details that are easy to get wrong

<!-- ::start:grid min="15rem" gap="4" -->

**`tabular-nums`** so 1 is as wide as 8. Without it a counter shifts sideways on
almost every frame, and nobody can name what is wrong - only that it looks
cheap.

**`scaleX`, not `width`** on the rail. A transform composites on its own layer;
a width relays out the page sixty times a second, competing with the WebGL
context it is covering for.

**A beat at a hundred** before it leaves. Replace the number in the same frame
it becomes correct and nobody ever sees it finish, which is the one moment this
component exists for.

**`onDone` in a ref**, so an inline arrow function from the parent does not tear
down and rebuild the animation loop on every render - which would leave the
count stuck at zero forever.

<!-- ::end:grid -->

## Reduced motion keeps it

The flourish goes; the loader stays.

Removing it entirely would be worse than useless. Somebody who asked for less
motion still needs to know something is happening, and a blank screen with no
explanation is not less motion - it is less information.

## Accessibility

`role="status"` and not `alert` - something loading is not an interruption.
`aria-busy` is what actually says "wait", and the digits are `aria-hidden`
because "zero four seven" is not information. The label carries the meaning.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `children?` | `ReactNode` | - | Drawn in the middle, above the counter. A spinning mark, usually. |
| `duration?` | `number` | `1600` | Roughly how long a full run takes, in milliseconds. |
| `ready?` | `boolean` | `true` | True once whatever this is waiting for has arrived. The count runs on its own and stalls near the end until this flips. That is the whole design: a progress bar that finishes before the thing it is measuring is a progress bar that lies, and one that only moves when real work reports in sits at zero for two seconds and looks broken. |
| `onDone?` | `() => void` | - | Called once the counter has reached a hundred and faded. |
| `label?` | `string` | `"Loading"` | Read out instead of the number, which is meaningless spoken. |

<!-- /generated:api -->

## Notes

`duration` only governs the climb to 90 - the run from 90 to 100 once
`ready` flips happens on its own fixed timing, not a fraction of
`duration`. Passing `ready={true}` from the first render skips the stall
entirely and the count runs straight through to 100.

`onDone` fires once, after the fade beat, and only if the component is
still mounted to see `ready` flip - unmounting it early, by routing away
mid-load, means the callback never runs, so nothing that has to happen
should depend on it firing.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="boot-loader" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { useState } from "react";
import { BootLoader } from "@sushindustries/ui";
import { ProductViewer } from "@sushindustries/react-product-viewer";

export function ModelSection() {
	const [loaded, setLoaded] = useState(false);

	return (
		<div className="device-screen">
			<ProductViewer src="/models/logo.glb" onLoad={() => setLoaded(true)} />
			<BootLoader ready={loaded} label="Loading model" />
		</div>
	);
}
```

## What this example is not

`BootLoader` renders on top of `ProductViewer` here, not instead of it -
both mount immediately, and the loader simply covers the canvas until
`ready` is true and then removes itself. It is not a suspense boundary or
a data fetcher; the host still owns deciding when `ready` should flip.
