05 / 22
All 22 in 22 CSS Progress Bars
- 01Native HTML Progress Element with ARIA Fallback
- 02Indeterminate Progress Bar with aria-busy State
- 03Fetch Upload Progress Bar with XHR onprogress Event
- 04Streaming Response Progress with ReadableStream Reader
- 05View Transition Page Load Bar (NProgress Style Route Change)
- 06Password Strength Meter with Live Feedback
- 07Download Queue Progress with Per File Bars and Overall Total
- 08Dark Mode Progress Bar with color-mix Tokens
- 09WCAG 2.2 Accessible Progress Bar with role progressbar Semantics
- 10Video Buffering Progress with Buffered Range Overlay
- 11Liquid Fill Progress Bar for Storage and Quota Meters
- 12Multi Step Form Completion Progress Tracker
- 13Conic Gradient Ring Progress Indicator
- 14Segmented Battery Style Progress Bar
- 15Animated Stripe Loading Bar
- 16Gradient Pulse Progress Bar
- 17Fundraising Goal Progress Tracker with Milestones
- 18Skill Level Progress Bars for Portfolio Sites
- 19Circular Counter Progress Ring with SVG Stroke Dashoffset
- 20Stacked Category Progress Bar for Budget and Analytics
- 21Speed Test Gauge Progress with Needle and Ticks
- 22Media Buffer Bar with Loaded and Playhead Positions
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.
Build interactive UI like this from scratch: 20 HTML, CSS & JavaScript projects
ZTM · JavaScript Web Projects · 30‑day money-back guarantee · Affiliate link
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><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;
}
}.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));
});
}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));
});
}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));
});
}
```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-sizeon.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
::afterspinner-cap, or delete that pseudo-element for a flat Vercel-style bar. - Recolour via
--accent; the gradient is generated from it withcolor-mix(). - Use it as a real router hook: call
start()in your framework's before-navigate event andfinish()in after-navigate.
Gotchas — read before shipping
position:fixedinside an element with atransform,filterorcontainanchors 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-sizehere costs layout on the exact frame the new route is painting. UsescaleX(). document.startViewTransitiondoes 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
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 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.