Feedback

Top Loading Bar

A thin route and request bar that climbs asymptotically while work is in flight, with a delay grace period, a minimum visible duration, a controlled mode and an optional spinner.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type TopLoadingBarColor = "primary" | "muted" | "destructive"

/**
 * Only tones that keep contrast in BOTH schemes ship as presets. For a brand or
 * "success" color, target the fill through its data-slot — `className` lands on
 * the root (the strip), not on the fill:
 *   className="[&>[data-slot=bar]]:bg-chart-2"
 */
const FILL: Record<TopLoadingBarColor, string> = {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/top-loading-bar.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "TopLoadingBar" component — the thin line
that crawls across the edge of the page while a route change or request is in
flight. React only: no animation library, no state library, no timers library.

Contract
- forwardRef<HTMLDivElement, TopLoadingBarProps> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "children">; "use client".
- Props:
  - loading = false — the single driver. Flip it on when work starts, off when
    it settles; every other behaviour is derived from that edge.
  - progress?: number — controlled percentage 0..100, clamped. Passing it turns
    OFF the internal climb: the bar renders exactly this number.
  - color = "primary" | "muted" | "destructive" (default "primary") — semantic
    tone for the fill AND for the spinner (the root carries the matching text-*
    token so the ring inherits it through currentColor). Deliberately only three
    presets: they are the tokens guaranteed to keep contrast in both schemes.
    Any other colour is a child-targeting class (see the levers), because
    className lands on the root strip, not on the fill.
  - height = 3 — px thickness, clamped to 1..24.
  - position = "top" | "bottom" (default "top").
  - fixed = true — true renders `position: fixed` (viewport); false renders
    `position: absolute`, pinning the bar to the nearest positioned ancestor so
    it can live inside a card, a dialog or a split pane.
  - showSpinner = false — a small spinning ring near the trailing edge.
  - delay = 200 — grace period in ms before anything is mounted.
  - minDuration = 500 — once visible, stay visible at least this long.
  - zIndex = 50 — inline style, because the right value depends on the host app.
  - className merges last via cn() onto the ROOT strip (so top-0 can become
    top-14 under a sticky header); style merges after the computed
    height/zIndex so consumers can override either.
- Clamp every numeric prop through one helper that also rejects NaN. A NaN
  height renders an invisible bar; a negative delay schedules a timer that
  fires immediately and forever.

Behavior — a four-phase machine: idle -> visible -> finish -> leave -> idle.
- loading true: start a `delay` timer; the timer's cleanup IS the delay
  guarantee — if loading drops before it fires, the bar is never mounted, so a
  120ms cache hit produces no flash at all.
- Entering "visible" stamps one timestamp used by both the curve and the
  minDuration floor.
- Auto mode (no progress prop): width = START + (CEILING - START) *
  (1 - exp(-elapsed / TAU)) with START 6, CEILING 99, TAU 1400ms. Fast off the
  line, ever slower as it approaches 99, mathematically unable to reach 100 —
  the bar can never claim the work is done while it is still running.
- Write that width straight to the bar node (ref.style.width) from a
  requestAnimationFrame loop. Never route a 60fps climb through useState: it
  would re-render — and, if the value were exposed to assistive tech,
  re-announce — on every frame for no visual gain.
- loading false while visible: wait out max(0, minDuration - elapsed), then
  "finish" (rush the width to 100 over ~220ms with an ease-out cubic), then
  "leave" (fade + slide off the edge over 300ms), then unmount.
- Controlled mode: React renders the width with a short width transition; there
  is no rush to 100 on finish, because the number belongs to the consumer — the
  documented recipe is "set progress to 100 before flipping loading off".
- Monotonic within a cycle: the curve only increases and finish only rushes up.
  A restart that arrives while the bar is still climbing simply keeps climbing
  (the clock is not re-stamped), so back-to-back navigations never reset it. The
  only backwards jump is a restart that lands after the bar already hit 100 —
  it resets to START, which reads as "new work", not "lost progress".
- Restart while the bar is still at 100 (the finish window): pick the new cycle
  up immediately instead of parking a full bar on screen for another `delay`.
  Do it with an adjust-state-during-render prevLoading comparison, never with a
  setState inside an effect body.
- Cleanup: every timeout, interval and rAF is cancelled in its effect's
  cleanup, and the paint helper bails when the node is already gone.

Accessibility
- Root: role="progressbar", aria-valuemin 0, aria-valuemax 100,
  aria-label="Loading" (overridable, since the props spread comes last).
- Controlled mode publishes aria-valuenow (the number is real).
- Auto mode does NOT: it omits aria-valuenow and sets aria-busy instead. The
  climb is an estimate, and shipping an estimate as a value is a lie to
  assistive tech. There is no live region either, so nothing is announced per
  frame; route-change announcements belong to the app's own status region.
- pointer-events-none: the bar reports, it never intercepts a click.

Rendering & styling
- Root: pointer-events-none inset-x-0, fixed/absolute per prop, top-0/bottom-0
  per position, inline height + zIndex, the tone's text-* token.
- Enter is instant (the climb itself is the entrance); only leave animates:
  transition-[opacity,translate] duration-300 ease-out. Tailwind v4's
  -translate-y-full writes the `translate` property, NOT `transform`, so
  `translate` must appear in the transition list or the bar changes state
  without animating.
- Bar child: h-full w-0 rounded-r-full plus the tone's bg-* token. w-0 is load
  bearing — in auto mode the first paint happens in an effect, and a
  width-less div would stretch to 100% for that frame.
- Spinner: a 16px animate-spin rounded-full border-2 border-current
  border-t-transparent ring, absolutely positioned right-3 with top-3 /
  bottom-3 so it hangs just off the bar; aria-hidden, and
  motion-reduce:animate-none.
- data-state={phase} on the root and data-slot="bar" on the fill give consumers
  and tests something stable to hook onto.
- prefers-reduced-motion (subscribed with useSyncExternalStore over matchMedia,
  server snapshot "motion allowed" — never read during render): drop the
  per-frame creep and all CSS transitions, but keep the state. The estimate
  advances in ~600ms discrete steps instead of 60fps, finish snaps to 100, and
  enter/leave become instant. Information is never carried by motion alone.

Customization levers
- Thickness and colour are the cheap knobs: height (1..24) and color. Beyond the
  three preset tones, colour the FILL through its data-slot —
  className="[&>[data-slot=bar]]:bg-chart-2" or
  "[&>[data-slot=bar]]:bg-gradient-to-r [&>[data-slot=bar]]:from-primary
  [&>[data-slot=bar]]:to-chart-2". className itself styles the root strip, which
  is what you want for a visible track (className="bg-muted") or for offsetting
  the bar below a sticky header (className="top-14").
- Timing personality lives in three numbers: TAU (how eager the climb feels),
  delay (how patient it is before showing) and minDuration (how long a shown
  bar must stay). Route bars want delay 150-250; a "saving" bar inside a form
  panel often wants delay 0 and a longer minDuration.
- Scope: fixed={false} inside a `relative` wrapper turns the page bar into a
  panel bar — same machine, bounded to a card. Multiple instances are
  independent; nothing is global.
- Placement: position="bottom" when a sticky header already owns the top edge.
- Add the classic NProgress peg glow with
  className="[&>[data-slot=bar]]:shadow-[0_0_8px_currentColor]" — again on the
  fill, so the glow travels with the leading edge instead of lighting up the
  whole strip.
- Swap the inline spinner for your own Spinner component if you already ship
  one — it is four utility classes, not a dependency.

Concepts

  • Asymptotic climb — with no duration to divide by, the width follows START + (CEILING - START) * (1 - exp(-t / TAU)): it sprints off the line, decelerates forever, and is mathematically incapable of reaching 100%. Only the completion event snaps it to full, so the bar can never claim work is finished while it is still running.
  • Delay grace period — the delay timer's cleanup is the feature. A request that resolves inside the window clears the pending timer, so nothing is ever mounted and a cache hit produces zero flash. Its mirror image is minDuration: once the bar is on screen it stays for a floor, because a bar that blinks out 30ms after appearing reads as a glitch, not as speed.
  • Monotonic within a cycle — the curve only ever increases and finish only rushes up. A second request that arrives while the bar is still climbing just keeps it climbing, so back-to-back navigations never yank it backwards; the one reset is a restart that lands after the bar already hit 100. In controlled mode the number is yours, so a decreasing progress is rendered faithfully rather than silently clamped.
  • Estimated is not aria-valuenow — auto mode omits the value and reports aria-busy instead, because publishing a guessed percentage to assistive tech is a lie. Controlled mode does publish it. Either way there is no live region, so a 60fps climb never turns into a screen-reader storm; the width is written straight to the DOM node and React re-renders only on phase changes.
  • Fixed and the containing blockposition: fixed is measured against the viewport until an ancestor has a transform, filter, perspective, contain: paint or will-change — then that ancestor becomes the containing block and the bar lands on it. That is why a page bar must be mounted outside every transformed subtree, and why fixed={false} plus a relative wrapper is the supported way to scope the bar to a card (the preview above uses both, on purpose).
  • State without animation — under prefers-reduced-motion the per-frame creep and every CSS transition are dropped, yet the bar still appears, still advances in coarse steps, still snaps to 100 and still disappears. Reduced motion removes the movement, not the information.

On This Page