Holdbar
The bar, and the Hold that keeps it open.
Usage
I render the bar once, next to the router outlet. It doesn't have to wrap anything: a Hold can sit anywhere in the tree, even outside the bar.
// app/-components/outlet.tsx
<HoldbarRoute />
<Outlet />
// features/holdbar/holdbar_route.tsx
<Holdbar className="text-primary">
{isLoading && <Holdbar.Hold />}
</Holdbar>Hold
A Hold is a hidden, empty span. It takes no props and you never see it: being on the page is all it does. So anything that knows it's loading can keep the bar open with plain JSX:
{isPending && <Holdbar.Hold />}
{mutation.isPending && <Holdbar.Hold />}
<Suspense fallback={<Holdbar.Hold />}>{children}</Suspense>When the first Hold mounts, the bar fades in and fills towards 90%, slowing down as it goes. When the last one unmounts, it runs to the end and fades out.
Colour
The bar is painted with currentColor, so its colour is the color of the element. In Tailwind that's a text utility, and any other class on the element works the same way:
<Holdbar className="text-primary mix-blend-difference" />Size and speed
height is the thickness of the bar in pixels. fillDuration is how long, in milliseconds, the bar takes to fill towards 90%: the fill slows down as it goes, so a longer duration reads as a slower load.
<Holdbar height={3} fillDuration={20000} />@import "react-holdbar/holdbar.css";
[data-holdbar]::before {
background: linear-gradient(to right, var(--color-sky-400), var(--color-violet-500));
}Minimum time
A load that ends after 20ms only gets the finish: the bar runs to the end and fades out. If you'd rather see it fill for a while first, minVisible keeps it filling for at least that many milliseconds, even if every Hold is already gone:
<Holdbar minVisible={400} />Whatever happens before, the finish is always the same: the bar runs to the end at full colour, then fades out, never already half faded. Both the minimum time and this finish need JavaScript. Before hydration the bar opens and closes with CSS alone, with a plain fade.
Reduced motion
With prefers-reduced-motion: reduce the bar doesn't fill: it fades in at full width and fades out again, without moving.
View transitions
During a view transition the browser captures the whole page, bar included, and animates the snapshot. detachFromViewTransition gives the bar its own view-transition-name and turns its transition animation off, so it stays live and on top while the page changes underneath.
<Holdbar detachFromViewTransition />Server Components
Holdbar and Holdbar.Hold both work from a Server Component. In the App Router the bar can sit in the root layout, and a page can use a Hold as its Suspense fallback without becoming a client component:
// app/layout.tsx
<body>
<Holdbar height={3} />
{children}
</body>
// app/blog/page.tsx
<Suspense fallback={<Holdbar.Hold />}>
<Posts />
</Suspense>Reference
Holdbar
| prop | type | default | |
|---|---|---|---|
height? | number | 2 | the thickness of the bar, in pixels |
fillDuration? | number | 12000 | milliseconds to fill towards 90% |
minVisible? | number | 0 | milliseconds the bar keeps filling at least, before it finishes |
detachFromViewTransition? | boolean | false | keeps the bar out of the page's view transition |
…rest | ComponentProps<"div"> | — | passed to the `div` |