22 CSS Progress Bars05 / 22

Light JSMIT licensed

View Transition Page Load Bar (NProgress Style Route Change)

The 3px bar at the top of the window that every SPA needs: it rushes to 80% in 200ms, holds while the route resolves, then completes and fades. Wired to document.startViewTransition() so the bar and the page swap belong to the same navigation.

Published

Live Demo
Try it

The code

<div class="pb-05">
  <span class="pb-05__bar" id="pb-05-bar" aria-hidden="true" data-done="true" style="--value:0"></span>

  <button class="pb-05__theme" type="button" aria-pressed="false" aria-label="Toggle dark or light theme">
    <svg class="pb-05__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <svg class="pb-05__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
  </button>

  <div class="pb-05__stage">
    <div class="pb-05__browser">
      <div class="pb-05__chrome">
        <span class="pb-05__lights" aria-hidden="true"><i></i><i></i><i></i></span>
        <p class="pb-05__url pb-05__mono" id="pb-05-url">codefronts.com<span>/guides</span></p>
        <span class="pb-05__badge pb-05__mono" id="pb-05-phase">idle</span>
      </div>

      <div class="pb-05__view" id="pb-05-view" aria-busy="false">
        <p class="pb-05__crumb pb-05__mono" id="pb-05-crumb">/ guides</p>
        <h2 class="pb-05__h" id="pb-05-h">Ship a router that feels instant</h2>
        <p class="pb-05__p" id="pb-05-p">The navigation bar at the top of this frame is doing the work: a 200ms rush to 80%, a hold while the route resolves, then a snap to 100% and a fade. Press the button and watch the timing, not the colour.</p>
        <ul class="pb-05__list" id="pb-05-list">
          <li><span class="pb-05__mono">01</span>Rush to 80% immediately</li>
          <li><span class="pb-05__mono">02</span>Hold — the server owns this part</li>
          <li><span class="pb-05__mono">03</span>Complete, then fade out</li>
        </ul>
      </div>

      <div class="pb-05__dock">
        <button class="pb-05__btn pb-05__btn--primary" type="button" id="pb-05-go">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M5 12h13M13 6.6l5.4 5.4L13 17.4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
          Navigate to /docs
        </button>
        <span class="pb-05__note pb-05__mono">startViewTransition() + scaleX bar</span>
      </div>
    </div>
    <p class="pb-05__live" id="pb-05-live" role="status" aria-live="polite"></p>
  </div>
</div>
.pb-05 {
  --bg: #08070e;
  --card: #0e0d18;
  --card2: #141324;
  --ink: #f1eeff;
  --muted: rgba(241,238,255,.6);
  --line: rgba(241,238,255,.12);
  --accent: #a78bfa;
  --accent-2: #818cf8;
  --sans: system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: ui-monospace,"JetBrains Mono","Geist Mono",SFMono-Regular,Menlo,monospace;
  inline-size: 100%;
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  box-sizing: border-box;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(76% .16 295)) {
  .pb-05 {
    --accent: oklch(76% .16 295);
    --accent-2: oklch(70% .16 277);
  }
}

.pb-05 *,
.pb-05 *::before,
.pb-05 *::after {
  box-sizing: border-box;
}

.pb-05__bar {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 20;
  block-size: 3px;
  display: block;
  transform: scaleX(calc(var(--value,0) / 100));
  transform-origin: left center;
  background: linear-gradient(90deg,var(--accent-2),var(--accent));
  box-shadow: 0 0 12px color-mix(in srgb,var(--accent) 70%,transparent);
  transition: transform .2s cubic-bezier(.4,0,.2,1),opacity .25s linear;
  will-change: transform,opacity;
}

.pb-05__bar[data-done="true"] {
  opacity: 0;
}

.pb-05__stage {
  display: grid;
  place-items: center;
  gap: 1rem;
  min-block-size: 100vh;
  padding: clamp(1.1rem,4vw,3.5rem);
  background: radial-gradient(100% 60% at 50% 0%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 60%);
}

.pb-05__theme {
  position: absolute;
  inset-block-start: clamp(1.1rem,2.6vw,1.8rem);
  inset-inline-end: clamp(.9rem,2.4vw,1.6rem);
  z-index: 25;
  inline-size: 2.5rem;
  block-size: 2.5rem;
  display: grid;
  place-items: center;
  cursor: pointer;
  border-radius: 99px;
  border: 1px solid var(--line);
  color: var(--ink);
  background: rgba(255,255,255,.06);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  transition: background .2s,transform .2s;
}

.pb-05__theme:hover {
  transform: translateY(-1px);
  background: rgba(255,255,255,.11);
}

.pb-05__theme:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.pb-05__theme svg {
  inline-size: 1.15rem;
  block-size: 1.15rem;
}

.pb-05__sun {
  display: none;
}

.pb-05__moon {
  display: block;
}

.pb-05[data-theme="light"] .pb-05__sun {
  display: block;
}

.pb-05[data-theme="light"] .pb-05__moon {
  display: none;
}

.pb-05__browser {
  inline-size: min(46rem,100%);
  border-radius: 1.3rem;
  overflow: hidden;
  border: 1px solid var(--line);
  background: color-mix(in srgb,var(--card) 94%,transparent);
  box-shadow: 0 1px 0 rgba(255,255,255,.05) inset,0 34px 80px -44px rgba(0,0,0,.95);
}

.pb-05__chrome {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: .8rem;
  align-items: center;
  padding: .7rem .9rem;
  border-block-end: 1px solid var(--line);
  background: color-mix(in srgb,var(--ink) 5%,transparent);
}

.pb-05__lights {
  display: flex;
  gap: .35rem;
}

.pb-05__lights i {
  inline-size: .6rem;
  block-size: .6rem;
  border-radius: 99px;
  background: color-mix(in srgb,var(--ink) 22%,transparent);
}

.pb-05__url {
  margin: 0;
  padding: .35rem .7rem;
  border-radius: .55rem;
  background: color-mix(in srgb,var(--ink) 6%,transparent);
  border: 1px solid var(--line);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pb-05__url span {
  color: var(--ink);
}

.pb-05__mono {
  font-family: var(--mono);
  font-size: .74rem;
  font-variant-numeric: tabular-nums;
}

.pb-05__badge {
  padding: .28rem .5rem;
  border-radius: .45rem;
  color: var(--accent);
  background: color-mix(in srgb,var(--accent) 15%,transparent);
  border: 1px solid color-mix(in srgb,var(--accent) 32%,transparent);
}

.pb-05__view {
  display: grid;
  gap: .7rem;
  padding: clamp(1.3rem,3.4vw,2.2rem);
  min-block-size: 15rem;
  align-content: start;
}

.pb-05__view[aria-busy="true"] {
  opacity: .55;
  transition: opacity .2s linear;
}

.pb-05__crumb {
  margin: 0;
  color: var(--accent);
  letter-spacing: .06em;
  text-transform: uppercase;
  font-size: .68rem;
}

.pb-05__h {
  margin: 0;
  font-size: clamp(1.4rem,3.4vw,2.05rem);
  line-height: 1.12;
  letter-spacing: -.022em;
  font-weight: 650;
  text-wrap: balance;
}

.pb-05__p {
  margin: 0;
  max-inline-size: 52ch;
  font-size: clamp(.9rem,1.5vw,1rem);
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}

.pb-05__list {
  list-style: none;
  margin: .3rem 0 0;
  padding: 0;
  display: grid;
  gap: 0;
}

.pb-05__list li {
  display: flex;
  gap: .7rem;
  align-items: baseline;
  padding-block: .55rem;
  border-block-start: 1px solid var(--line);
  font-size: .88rem;
  color: var(--ink);
}

.pb-05__list span {
  color: var(--accent);
}

.pb-05__dock {
  display: flex;
  flex-wrap: wrap;
  gap: .8rem;
  align-items: center;
  padding: .9rem clamp(1.1rem,3vw,2.2rem) 1.1rem;
  border-block-start: 1px solid var(--line);
  background: color-mix(in srgb,var(--ink) 4%,transparent);
}

.pb-05__btn {
  display: inline-flex;
  align-items: center;
  gap: .45rem;
  min-block-size: 2.75rem;
  padding: .65rem 1.1rem;
  border-radius: .85rem;
  border: 1px solid var(--line);
  background: color-mix(in srgb,var(--ink) 6%,transparent);
  color: var(--ink);
  font: inherit;
  font-size: .88rem;
  font-weight: 550;
  cursor: pointer;
  transition: transform .18s,background .18s;
}

.pb-05__btn svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
}

.pb-05__btn:hover {
  transform: translateY(-1px);
}

.pb-05__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.pb-05__btn--primary {
  background: var(--accent);
  border-color: transparent;
  color: #170c2b;
  box-shadow: 0 12px 26px -16px var(--accent);
}

.pb-05__btn--primary:hover {
  background: color-mix(in srgb,var(--accent) 88%,white);
}

.pb-05__note {
  color: var(--muted);
}

.pb-05__live {
  margin: 0;
  min-block-size: 1.1rem;
  font-size: .78rem;
  color: var(--muted);
}

.pb-05[data-theme="light"] {
  --bg: #f7f6fd;
  --card: #ffffff;
  --card2: #faf9ff;
  --ink: #150c28;
  --muted: rgba(21,12,40,.62);
  --line: rgba(21,12,40,.1);
  --accent: #7c3aed;
  --accent-2: #4f46e5;
}

.pb-05[data-theme="light"] .pb-05__theme {
  background: rgba(21,12,40,.05);
}

.pb-05[data-theme="light"] .pb-05__btn--primary {
  color: #fff;
}

@media (prefers-color-scheme: light) {
  .pb-05:not([data-theme="dark"]) {
    --bg: #f7f6fd;
    --card: #ffffff;
    --card2: #faf9ff;
    --ink: #150c28;
    --muted: rgba(21,12,40,.62);
    --line: rgba(21,12,40,.1);
    --accent: #7c3aed;
    --accent-2: #4f46e5;
  }

  .pb-05:not([data-theme="dark"]) .pb-05__theme {
    background: rgba(21,12,40,.05);
  }

  .pb-05:not([data-theme="dark"]) .pb-05__btn--primary {
    color: #fff;
  }

  .pb-05:not([data-theme="dark"]) .pb-05__sun {
    display: block;
  }

  .pb-05:not([data-theme="dark"]) .pb-05__moon {
    display: none;
  }

  .pb-05[data-theme="dark"] .pb-05__sun {
    display: none;
  }

  .pb-05[data-theme="dark"] .pb-05__moon {
    display: block;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pb-05 *,
    .pb-05 *::before,
    .pb-05 *::after {
    animation: none !important;
    transition: none !important;
  }
}

@media (forced-colors: active) {
  .pb-05__browser,
    .pb-05__btn,
    .pb-05__theme,
    .pb-05__url {
    border-color: CanvasText;
    box-shadow: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  .pb-05__bar {
    background: Highlight;
    box-shadow: none;
  }
}
const pb05 = document.querySelector('.pb-05');
if (pb05) {
  const q = (s) => pb05.querySelector(s);
  const [bar, view, phase, url, live] = ['#pb-05-bar','#pb-05-view','#pb-05-phase','#pb-05-url','#pb-05-live'].map(q);
  const ROUTES = [
    { path: '/docs', crumb: '/ docs', h: 'Install in one command', p: 'Route swapped through startViewTransition(), so the bar and the content change belong to the same frame budget. Nothing here waited on a spinner.', items: ['Rush, hold, complete', 'No layout during the swap', 'Fallback when the API is missing'] },
    { path: '/guides', crumb: '/ guides', h: 'Ship a router that feels instant', p: 'The navigation bar at the top of this frame is doing the work: a 200ms rush to 80%, a hold while the route resolves, then a snap to 100% and a fade.', items: ['Rush to 80% immediately', 'Hold — the server owns this part', 'Complete, then fade out'] },
  ];
  const set = (v) => bar.style.setProperty('--value', v);
  let i = 0;
  const paint = (r) => {
    q('#pb-05-crumb').textContent = r.crumb; q('#pb-05-h').textContent = r.h; q('#pb-05-p').textContent = r.p;
    q('#pb-05-list').innerHTML = r.items.map((t, n) => '<li><span class="pb-05__mono">0' + (n + 1) + '</span>' + t + '</li>').join('');
    url.innerHTML = 'codefronts.com<span>' + r.path + '</span>';
    q('#pb-05-go').lastChild.textContent = ' Navigate to ' + ROUTES[(i + 1) % 2].path;
  };
  const navigate = async () => {
    const soft = matchMedia('(prefers-reduced-motion: reduce)').matches;
    i = (i + 1) % ROUTES.length; const next = ROUTES[i];
    bar.dataset.done = 'false'; set(0); phase.textContent = 'rush'; view.setAttribute('aria-busy', 'true');
    requestAnimationFrame(() => set(80));
    await new Promise((r) => setTimeout(r, soft ? 0 : 520)); phase.textContent = 'hold';
    const swap = () => paint(next);
    if (!soft && document.startViewTransition) await document.startViewTransition(swap).updateCallbackDone; else swap();
    set(100); phase.textContent = 'done'; view.setAttribute('aria-busy', 'false');
    live.textContent = 'Navigated to ' + next.path + '. ' + next.h + '.';
    setTimeout(() => { bar.dataset.done = 'true'; setTimeout(() => set(0), 260); }, 220);
  };
  q('#pb-05-go').addEventListener('click', navigate);
  q('.pb-05__theme').addEventListener('click', (e) => {
    const light = pb05.dataset.theme !== 'light';
    pb05.dataset.theme = light ? 'light' : 'dark';
    e.currentTarget.setAttribute('aria-pressed', String(!light));
  });
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Progress Bar from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: View Transition Page Load Bar (NProgress Style Route Change)
Source: https://codefronts.com/components/css-progress-bars/view-transition-page-load-bar-nprogress-style-route-change/

The 3px bar at the top of the window that every SPA needs: it rushes to 80% in 200ms, holds while the route resolves, then completes and fades. Wired to document.startViewTransition() so the bar and the page swap belong to the same navigation.
## HTML
```html
<div class="pb-05">
  <span class="pb-05__bar" id="pb-05-bar" aria-hidden="true" data-done="true" style="--value:0"></span>

  <button class="pb-05__theme" type="button" aria-pressed="false" aria-label="Toggle dark or light theme">
    <svg class="pb-05__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <svg class="pb-05__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
  </button>

  <div class="pb-05__stage">
    <div class="pb-05__browser">
      <div class="pb-05__chrome">
        <span class="pb-05__lights" aria-hidden="true"><i></i><i></i><i></i></span>
        <p class="pb-05__url pb-05__mono" id="pb-05-url">codefronts.com<span>/guides</span></p>
        <span class="pb-05__badge pb-05__mono" id="pb-05-phase">idle</span>
      </div>

      <div class="pb-05__view" id="pb-05-view" aria-busy="false">
        <p class="pb-05__crumb pb-05__mono" id="pb-05-crumb">/ guides</p>
        <h2 class="pb-05__h" id="pb-05-h">Ship a router that feels instant</h2>
        <p class="pb-05__p" id="pb-05-p">The navigation bar at the top of this frame is doing the work: a 200ms rush to 80%, a hold while the route resolves, then a snap to 100% and a fade. Press the button and watch the timing, not the colour.</p>
        <ul class="pb-05__list" id="pb-05-list">
          <li><span class="pb-05__mono">01</span>Rush to 80% immediately</li>
          <li><span class="pb-05__mono">02</span>Hold — the server owns this part</li>
          <li><span class="pb-05__mono">03</span>Complete, then fade out</li>
        </ul>
      </div>

      <div class="pb-05__dock">
        <button class="pb-05__btn pb-05__btn--primary" type="button" id="pb-05-go">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M5 12h13M13 6.6l5.4 5.4L13 17.4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
          Navigate to /docs
        </button>
        <span class="pb-05__note pb-05__mono">startViewTransition() + scaleX bar</span>
      </div>
    </div>
    <p class="pb-05__live" id="pb-05-live" role="status" aria-live="polite"></p>
  </div>
</div>
```
## CSS
```css
.pb-05 {
  --bg: #08070e;
  --card: #0e0d18;
  --card2: #141324;
  --ink: #f1eeff;
  --muted: rgba(241,238,255,.6);
  --line: rgba(241,238,255,.12);
  --accent: #a78bfa;
  --accent-2: #818cf8;
  --sans: system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: ui-monospace,"JetBrains Mono","Geist Mono",SFMono-Regular,Menlo,monospace;
  inline-size: 100%;
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  box-sizing: border-box;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(76% .16 295)) {
  .pb-05 {
    --accent: oklch(76% .16 295);
    --accent-2: oklch(70% .16 277);
  }
}

.pb-05 *,
.pb-05 *::before,
.pb-05 *::after {
  box-sizing: border-box;
}

.pb-05__bar {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 20;
  block-size: 3px;
  display: block;
  transform: scaleX(calc(var(--value,0) / 100));
  transform-origin: left center;
  background: linear-gradient(90deg,var(--accent-2),var(--accent));
  box-shadow: 0 0 12px color-mix(in srgb,var(--accent) 70%,transparent);
  transition: transform .2s cubic-bezier(.4,0,.2,1),opacity .25s linear;
  will-change: transform,opacity;
}

.pb-05__bar[data-done="true"] {
  opacity: 0;
}

.pb-05__stage {
  display: grid;
  place-items: center;
  gap: 1rem;
  min-block-size: 100vh;
  padding: clamp(1.1rem,4vw,3.5rem);
  background: radial-gradient(100% 60% at 50% 0%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 60%);
}

.pb-05__theme {
  position: absolute;
  inset-block-start: clamp(1.1rem,2.6vw,1.8rem);
  inset-inline-end: clamp(.9rem,2.4vw,1.6rem);
  z-index: 25;
  inline-size: 2.5rem;
  block-size: 2.5rem;
  display: grid;
  place-items: center;
  cursor: pointer;
  border-radius: 99px;
  border: 1px solid var(--line);
  color: var(--ink);
  background: rgba(255,255,255,.06);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  transition: background .2s,transform .2s;
}

.pb-05__theme:hover {
  transform: translateY(-1px);
  background: rgba(255,255,255,.11);
}

.pb-05__theme:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.pb-05__theme svg {
  inline-size: 1.15rem;
  block-size: 1.15rem;
}

.pb-05__sun {
  display: none;
}

.pb-05__moon {
  display: block;
}

.pb-05[data-theme="light"] .pb-05__sun {
  display: block;
}

.pb-05[data-theme="light"] .pb-05__moon {
  display: none;
}

.pb-05__browser {
  inline-size: min(46rem,100%);
  border-radius: 1.3rem;
  overflow: hidden;
  border: 1px solid var(--line);
  background: color-mix(in srgb,var(--card) 94%,transparent);
  box-shadow: 0 1px 0 rgba(255,255,255,.05) inset,0 34px 80px -44px rgba(0,0,0,.95);
}

.pb-05__chrome {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: .8rem;
  align-items: center;
  padding: .7rem .9rem;
  border-block-end: 1px solid var(--line);
  background: color-mix(in srgb,var(--ink) 5%,transparent);
}

.pb-05__lights {
  display: flex;
  gap: .35rem;
}

.pb-05__lights i {
  inline-size: .6rem;
  block-size: .6rem;
  border-radius: 99px;
  background: color-mix(in srgb,var(--ink) 22%,transparent);
}

.pb-05__url {
  margin: 0;
  padding: .35rem .7rem;
  border-radius: .55rem;
  background: color-mix(in srgb,var(--ink) 6%,transparent);
  border: 1px solid var(--line);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pb-05__url span {
  color: var(--ink);
}

.pb-05__mono {
  font-family: var(--mono);
  font-size: .74rem;
  font-variant-numeric: tabular-nums;
}

.pb-05__badge {
  padding: .28rem .5rem;
  border-radius: .45rem;
  color: var(--accent);
  background: color-mix(in srgb,var(--accent) 15%,transparent);
  border: 1px solid color-mix(in srgb,var(--accent) 32%,transparent);
}

.pb-05__view {
  display: grid;
  gap: .7rem;
  padding: clamp(1.3rem,3.4vw,2.2rem);
  min-block-size: 15rem;
  align-content: start;
}

.pb-05__view[aria-busy="true"] {
  opacity: .55;
  transition: opacity .2s linear;
}

.pb-05__crumb {
  margin: 0;
  color: var(--accent);
  letter-spacing: .06em;
  text-transform: uppercase;
  font-size: .68rem;
}

.pb-05__h {
  margin: 0;
  font-size: clamp(1.4rem,3.4vw,2.05rem);
  line-height: 1.12;
  letter-spacing: -.022em;
  font-weight: 650;
  text-wrap: balance;
}

.pb-05__p {
  margin: 0;
  max-inline-size: 52ch;
  font-size: clamp(.9rem,1.5vw,1rem);
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}

.pb-05__list {
  list-style: none;
  margin: .3rem 0 0;
  padding: 0;
  display: grid;
  gap: 0;
}

.pb-05__list li {
  display: flex;
  gap: .7rem;
  align-items: baseline;
  padding-block: .55rem;
  border-block-start: 1px solid var(--line);
  font-size: .88rem;
  color: var(--ink);
}

.pb-05__list span {
  color: var(--accent);
}

.pb-05__dock {
  display: flex;
  flex-wrap: wrap;
  gap: .8rem;
  align-items: center;
  padding: .9rem clamp(1.1rem,3vw,2.2rem) 1.1rem;
  border-block-start: 1px solid var(--line);
  background: color-mix(in srgb,var(--ink) 4%,transparent);
}

.pb-05__btn {
  display: inline-flex;
  align-items: center;
  gap: .45rem;
  min-block-size: 2.75rem;
  padding: .65rem 1.1rem;
  border-radius: .85rem;
  border: 1px solid var(--line);
  background: color-mix(in srgb,var(--ink) 6%,transparent);
  color: var(--ink);
  font: inherit;
  font-size: .88rem;
  font-weight: 550;
  cursor: pointer;
  transition: transform .18s,background .18s;
}

.pb-05__btn svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
}

.pb-05__btn:hover {
  transform: translateY(-1px);
}

.pb-05__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.pb-05__btn--primary {
  background: var(--accent);
  border-color: transparent;
  color: #170c2b;
  box-shadow: 0 12px 26px -16px var(--accent);
}

.pb-05__btn--primary:hover {
  background: color-mix(in srgb,var(--accent) 88%,white);
}

.pb-05__note {
  color: var(--muted);
}

.pb-05__live {
  margin: 0;
  min-block-size: 1.1rem;
  font-size: .78rem;
  color: var(--muted);
}

.pb-05[data-theme="light"] {
  --bg: #f7f6fd;
  --card: #ffffff;
  --card2: #faf9ff;
  --ink: #150c28;
  --muted: rgba(21,12,40,.62);
  --line: rgba(21,12,40,.1);
  --accent: #7c3aed;
  --accent-2: #4f46e5;
}

.pb-05[data-theme="light"] .pb-05__theme {
  background: rgba(21,12,40,.05);
}

.pb-05[data-theme="light"] .pb-05__btn--primary {
  color: #fff;
}

@media (prefers-color-scheme: light) {
  .pb-05:not([data-theme="dark"]) {
    --bg: #f7f6fd;
    --card: #ffffff;
    --card2: #faf9ff;
    --ink: #150c28;
    --muted: rgba(21,12,40,.62);
    --line: rgba(21,12,40,.1);
    --accent: #7c3aed;
    --accent-2: #4f46e5;
  }

  .pb-05:not([data-theme="dark"]) .pb-05__theme {
    background: rgba(21,12,40,.05);
  }

  .pb-05:not([data-theme="dark"]) .pb-05__btn--primary {
    color: #fff;
  }

  .pb-05:not([data-theme="dark"]) .pb-05__sun {
    display: block;
  }

  .pb-05:not([data-theme="dark"]) .pb-05__moon {
    display: none;
  }

  .pb-05[data-theme="dark"] .pb-05__sun {
    display: none;
  }

  .pb-05[data-theme="dark"] .pb-05__moon {
    display: block;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pb-05 *,
    .pb-05 *::before,
    .pb-05 *::after {
    animation: none !important;
    transition: none !important;
  }
}

@media (forced-colors: active) {
  .pb-05__browser,
    .pb-05__btn,
    .pb-05__theme,
    .pb-05__url {
    border-color: CanvasText;
    box-shadow: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  .pb-05__bar {
    background: Highlight;
    box-shadow: none;
  }
}
```

## JavaScript
```js
const pb05 = document.querySelector('.pb-05');
if (pb05) {
  const q = (s) => pb05.querySelector(s);
  const [bar, view, phase, url, live] = ['#pb-05-bar','#pb-05-view','#pb-05-phase','#pb-05-url','#pb-05-live'].map(q);
  const ROUTES = [
    { path: '/docs', crumb: '/ docs', h: 'Install in one command', p: 'Route swapped through startViewTransition(), so the bar and the content change belong to the same frame budget. Nothing here waited on a spinner.', items: ['Rush, hold, complete', 'No layout during the swap', 'Fallback when the API is missing'] },
    { path: '/guides', crumb: '/ guides', h: 'Ship a router that feels instant', p: 'The navigation bar at the top of this frame is doing the work: a 200ms rush to 80%, a hold while the route resolves, then a snap to 100% and a fade.', items: ['Rush to 80% immediately', 'Hold — the server owns this part', 'Complete, then fade out'] },
  ];
  const set = (v) => bar.style.setProperty('--value', v);
  let i = 0;
  const paint = (r) => {
    q('#pb-05-crumb').textContent = r.crumb; q('#pb-05-h').textContent = r.h; q('#pb-05-p').textContent = r.p;
    q('#pb-05-list').innerHTML = r.items.map((t, n) => '<li><span class="pb-05__mono">0' + (n + 1) + '</span>' + t + '</li>').join('');
    url.innerHTML = 'codefronts.com<span>' + r.path + '</span>';
    q('#pb-05-go').lastChild.textContent = ' Navigate to ' + ROUTES[(i + 1) % 2].path;
  };
  const navigate = async () => {
    const soft = matchMedia('(prefers-reduced-motion: reduce)').matches;
    i = (i + 1) % ROUTES.length; const next = ROUTES[i];
    bar.dataset.done = 'false'; set(0); phase.textContent = 'rush'; view.setAttribute('aria-busy', 'true');
    requestAnimationFrame(() => set(80));
    await new Promise((r) => setTimeout(r, soft ? 0 : 520)); phase.textContent = 'hold';
    const swap = () => paint(next);
    if (!soft && document.startViewTransition) await document.startViewTransition(swap).updateCallbackDone; else swap();
    set(100); phase.textContent = 'done'; view.setAttribute('aria-busy', 'false');
    live.textContent = 'Navigated to ' + next.path + '. ' + next.h + '.';
    setTimeout(() => { bar.dataset.done = 'true'; setTimeout(() => set(0), 260); }, 220);
  };
  q('#pb-05-go').addEventListener('click', navigate);
  q('.pb-05__theme').addEventListener('click', (e) => {
    const light = pb05.dataset.theme !== 'light';
    pb05.dataset.theme = light ? 'light' : 'dark';
    e.currentTarget.setAttribute('aria-pressed', String(!light));
  });
}
```

How this works

The bar lies on purpose, but only in one direction. It advances quickly to 80% because the first phase of a navigation is fast and predictable; then it waits, because the rest depends on the server. It never goes backwards, and it never reaches 100% until the route is actually live.

const bar = document.querySelector('.pb-05__bar');
const set = (v) => bar.style.setProperty('--value', v);

const start = () => { set(0); requestAnimationFrame(() => set(80)); };  // rush
const finish = () => { set(100); setTimeout(() => bar.dataset.done = 'true', 220); };

Scale, do not resize. A bar that animates inline-size during a route change competes with the new page's layout for main-thread time — exactly when you can least afford it:

.pb-05__bar{
  transform:scaleX(calc(var(--value) / 100));
  transform-origin:left center;
  transition:transform .2s cubic-bezier(.4,0,.2,1),opacity .25s linear;
  will-change:transform,opacity;
}

Pair it with the View Transition API. The bar covers the network phase; startViewTransition covers the visual swap. Together they read as one continuous motion:

async function navigate(url){
  start();
  const html = await fetch(url).then((r) => r.text());
  if (!document.startViewTransition) { swap(html); return finish(); }
  const t = document.startViewTransition(() => swap(html));
  await t.updateCallbackDone;
  finish();
}

The bar is decoration; the region is the truth. Screen reader users are not served by a 3px line, so mark it aria-hidden="true" and put aria-busy on the content region plus one polite announcement when the new route's heading is in place.

Make it yours

  • Retune the personality with two numbers: the rush target (80) and the rush duration (.2s). Higher target plus slower duration feels heavier and more "native app".
  • Thicken to a modern 4px or thin to a hairline 2px with the single block-size on .pb-05__bar.
  • Add the classic trickle by stepping the value up 1-2% on an interval while waiting, capped below the rush target.
  • Give it a leading glow by keeping the ::after spinner-cap, or delete that pseudo-element for a flat Vercel-style bar.
  • Recolour via --accent; the gradient is generated from it with color-mix().
  • Use it as a real router hook: call start() in your framework's before-navigate event and finish() in after-navigate.

Gotchas — read before shipping

  • position:fixed inside an element with a transform, filter or contain anchors to that element, not the viewport. In a real app mount the bar at the top of <body>.
  • Never let the bar reach 100% on a timer. If the route fails, the bar must reset or stay held — a completed bar over a broken page is worse than no bar.
  • Animating inline-size here costs layout on the exact frame the new route is painting. Use scaleX().
  • document.startViewTransition does not exist in Firefox and older Safari; always branch, never assume — an unguarded call throws and kills the navigation.
  • Do not put role="progressbar" on this bar. It carries no meaningful value for AT; announce the route change on the region instead.
  • Under prefers-reduced-motion, skip the animation and the view transition — jump straight to the finished state.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), color-mix(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+ (View Transitions).

The bar itself is plain CSS transforms and works everywhere. document.startViewTransition is Chrome 111+ and Safari 18+; the demo branches on its presence so Firefox gets an instant swap with the same bar timing.

Techniques used in this demo

Search CodeFronts

Loading…