webdevpuneet.com

Page Transition Progress Bar — Route-Change Loading Bar in HTML CSS JS

Page Transition Progress Bar · Loaders · Plain HTML, CSS & JS · Live preview

CategoryLoaders

Copy & paste

Source Code

<div class="pt-bar" id="ptBar"></div>

<div class="pt-demo">
  <div class="pt-window">
    <div class="pt-current" id="ptCurrent">/dashboard</div>
    <nav class="pt-links">
      <button type="button" class="pt-link" data-to="/settings">/settings</button>
      <button type="button" class="pt-link" data-to="/reports">/reports</button>
      <button type="button" class="pt-link" data-to="/billing">/billing</button>
    </nav>
  </div>
  <p class="pt-hint">Click a route — the bar climbs, holds mid-flight, then completes.</p>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Three-phase curve
Climb to 82%, hold, then complete to 100% — not a single linear fill.
Different easing per phase
A snappy climb and a softer completion ease, not one uniform transition.
No per-frame JS loop
Pure CSS width transitions per phase — no requestAnimationFrame ticking.
Tied to a real navigation event
Triggered by a route click, not a standalone demo button.
Explicit hold phase
A fixed pause stands in for the request actually being in flight.
Clean reset
A forced reflow ensures the width resets to 0% without animating backward.
Fade-and-reset finish
Opacity fades out after completion, then width resets for the next run.
Framework-portable
A single navigate(path) function is all you need to call from a router hook.

About this UI Snippet

Page Transition Progress Bar — Tied to a Simulated Navigation Lifecycle

Screenshot of the Page Transition Progress Bar snippet rendered live

This is the thin bar that slides across the top of the page during a client-side route change — the same family as a top loading bar, but built around a different mechanic: instead of a continuous rAF-eased trickle toward 90%, this snippet drives the bar through three discrete, explicit phases of one simulated navigation — climb, hold, complete — using plain CSS width transitions with different easing per phase, triggered by clicking a route link in the demo.

How this differs from a continuous trickle bar

A trickle-style bar (see the top loading bar snippet) uses a setInterval to keep nudging a target value upward and a requestAnimationFrame loop to ease the displayed width toward it continuously, so the width is recalculated every frame for as long as the load takes. This snippet takes a simpler, phase-based approach on purpose: a single navigate() call sets the width straight to 82% with one CSS transition, waits a fixed hold period (standing in for "request in flight"), then transitions straight to 100% with a second, different easing curve. There is no per-frame JavaScript loop at all — every motion is a CSS width transition, and the "curve" comes from choosing three different states and three different transition timings rather than continuously recomputing a target.

Three explicit phases, not one continuous animation

navigate(path) resets the bar to 0% (with the transition disabled and a forced reflow so the reset isn't animated), then transitions to 82% quickly with a snappy cubic-bezier, holds unchanged for 900ms while nothing animates (simulating the actual request being in flight), then transitions the remaining distance to 100% with a softer ease-out — visibly two different speeds for the "climb" and "complete" segments, which is what a realistic route change looks like: fast optimistic progress, a pause for the real work, then a quick finish.

Route demo, not a generic trigger

Clicking one of the three route buttons calls navigate() with that path and, once the bar completes and fades, updates a "Now at" label — tying the whole animation explicitly to a simulated navigation event rather than a standalone play button, closer to how you'd wire this to real router lifecycle events (beforeEach/afterEach, routeChangeStart/routeChangeComplete).

Wiring it to a real router

Call navigate(path) — or split it into a start()/finish() pair if your router's start and complete events fire far apart — from your router's navigation hooks. Pair it with a skeleton loader for the incoming page's content placeholder, or an indeterminate bar for in-page async work that isn't a full navigation.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA mock app window with three route buttons renders below a hidden top bar.
  2. 2
    Click a route buttonThe bar appears and climbs quickly to 82% with a snappy ease.
  3. 3
    Watch it holdThe bar pauses at 82% for ~900ms — the simulated in-flight request.
  4. 4
    Watch it completeIt eases the rest of the way to 100%, then fades out and resets.
  5. 5
    See the route updateOnce the bar fades, the "current route" label updates to the clicked path.
  6. 6
    Wire to a real routerCall navigate() from your router's navigation start hook with the target path.

Real-world uses

Common Use Cases

SPA route transitions
Fire on router navigation start and finish, climbing to 82% fast, holding while data loads, then completing to 100% and fading.
Client-side page loads
Pair with a skeleton loader so the bar signals the navigation while placeholders fill the new view.
View and tab switches
Signal a heavier in-app view change, using pure CSS width transitions per phase with no per-frame JavaScript loop.
Multi-step navigation
Pair with a segmented progress indicator for long flows, while the bar handles each page change.
Loading curve comparisons
Compare the climb, hold and complete phases with a top loading bar and an indeterminate bar.
Related: Progress Bar with Milestone Labels
See the Progress Bar with Milestone Labels for a related loaders pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

The top loading bar uses a continuous requestAnimationFrame loop plus a setInterval "trickle" that keeps nudging a moving target toward 90% for as long as the load takes, recalculating width every frame. This snippet instead defines three fixed phases — climb to 82%, hold for a set duration, complete to 100% — each driven by a single CSS width transition with its own easing curve, and there is no per-frame JavaScript at all. It trades the trickle bar's open-ended "keep faking progress" behavior for a fixed, three-beat curve tied to one simulated navigation.

The hold represents the request actually being in flight — the point where the app has no more information to fake progress with and is genuinely waiting. Freezing the bar there for a realistic pause (900ms in the demo) rather than continuing to inch upward makes the eventual jump to 100% read as a real completion event rather than an animation simply running out.

Call navigate(path) when your router's navigation starts (e.g. Vue Router's beforeEach, Next.js's routeChangeStart, React Router's navigation start). If your router exposes separate start and complete events, split navigate() into two functions — one that climbs to 82% and holds indefinitely, and one that completes to 100% and fades — and call the second when the real navigation actually resolves instead of using a fixed timeout.

Because this bar's motion is a fixed, known sequence of states (0% to 82% to 100%) rather than an open-ended approach toward an uncertain endpoint, a CSS transition per phase is simpler and needs no per-frame JavaScript. The trickle bar's rAF loop exists specifically because its target keeps moving unpredictably; this bar's targets are fixed values, so CSS transitions are sufficient and lighter-weight.

Wrap navigate(path) in a small hook or service and call it from your router's navigation guards — a useEffect tied to route changes in React, a beforeEach/afterEach pair in Vue Router, or a Router event subscription in Angular. Keep the bar as a single fixed element outside your route-specific components so it persists across navigations.