---
title: Page Progress
description: A page-edge bar that reports the request the reader is waiting on — navigation and Livewire round trips, without a per-screen opt-in.
category: Display
related:
  - /components/progress
  - /components/reading
  - /components/spinner
  - /components/skeleton
visibility: guest
draft: false
---

# Page Progress

A thin bar at the top edge of the page that appears while something is loading and
retires when the answer lands. Mount it **once**, in your layout. It needs no opt-in
per screen: it hooks Livewire's message lifecycle and the `wire:navigate` events, so a
screen you build tomorrow is covered without anyone remembering to add anything to it.

It reports **waiting**, not work. A round trip has no percentage, so the bar eases
towards a ceiling and slows the longer it waits; only the answer takes it to the end.

:::preview{title="Start a request, then answer it", frame="iframe", height="260px", flush="true"}
<x-wirekit::page-progress />
{{-- The wrapper carries `x-data` so `$dispatch` has a scope to fire from. Without it Alpine
     never attaches the handler and both buttons are dead, silently, with nothing in the
     console — which on a page whose whole subject is a control that reports is the one
     defect a reader would not question. --}}
<div x-data>
<x-wirekit::stack gap="md" style="padding: 1.5rem;">
    <x-wirekit::text>
        The first button opens a request, the second answers it. Watch the top edge of
        this frame. Press them in quick succession and nothing appears at all — that is
        the delay doing its job.
    </x-wirekit::text>
    <x-wirekit::row gap="sm" wrap>
        <x-wirekit::button x-on:click="$dispatch('livewire:navigate')">Open a request</x-wirekit::button>
        <x-wirekit::button surface="outline" intent="neutral" x-on:click="$dispatch('livewire:navigated')">Answer it</x-wirekit::button>
    </x-wirekit::row>
</x-wirekit::stack>
</div>
:::

:::source{language="blade"}
{{-- In your layout, once. Nothing else is needed: the component finds the
     requests by itself. --}}
<x-wirekit::page-progress />
:::

The two buttons above exist only to drive the demonstration. In an application nothing
dispatches these events by hand — Livewire does, on every navigation and every
component round trip.

## Turn off Livewire's own bar

Livewire ships a navigation progress bar of its own, on by default. It reports
`wire:navigate` and never a component round trip, and it paints above anything you
mount yourself — so leaving it on gives you two bars stacked on each other.

```php
// config/livewire.php
'navigate' => [
    'show_progress_bar' => false,
],
```

Livewire reads that setting on the server, so this component cannot switch it off for
you. It is one line, and it is the only setup step.

## The delay is the feature

Most requests finish inside 180ms and show nothing. That is deliberate: a bar that
blinks on every click is noise, and noise is exactly what stops a reader noticing the
one request that really is slow. Raise `showAfter` on a fast internal tool, lower it on
a surface where most actions are slow anyway.

:::info
The bar never reaches 100% on its own. `ceiling` is where the easing stops, so `100`
keeps meaning **finished** rather than *the animation ran out*. A bar that fills to the
end on a timer lies on the first request that runs long.
:::

## A poll is not a request the reader made

A screen with `wire:poll` makes a round trip every few seconds on its own. Those are
skipped: the bar only reports messages the reader caused.

The test is Livewire's own metadata — a message every one of whose actions is tagged as
a poll is ignored. A message with *no* actions is a component refresh, not a poll, and
is reported.

:::warning
This is the defect that makes a page-edge bar worthless rather than merely imperfect.
A bar running through every three seconds on its own teaches its reader to ignore it,
and then it no longer reports the waiting that *is* worth reporting.
:::

## When to reach for something else

| You want to show | Use |
|---|---|
| A value somebody knows — an upload percentage, seats used | [progress](/components/progress) |
| How far through an article the reader has scrolled | [reading-progress](/components/reading) |
| That one element is waiting, in place | [spinner](/components/spinner) |
| The shape of content that has not arrived | [skeleton](/components/skeleton) |

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `height` | `sm` \| `md` \| `lg` | `md` | Thickness of the strip. `md` is 3px. |
| `intent` | `primary` \| `neutral` \| `success` \| `warning` \| `danger` \| `info` \| `auto` | `primary` | Fill color. `info` is a synonym of `primary`; `auto` resolves to `currentColor` for an embedded surface that should match the text around it. |
| `showAfter` | `int` (ms) | `180` | How long a request has to run before the bar appears at all. |
| `ceiling` | `int` (%) | `92` | The percentage the easing approaches while it waits. |
| `scope` | `string\|null` | `null` | Scoped personalization key. |

## Accessibility

The bar carries `aria-hidden="true"`, and that is a decision rather than an omission. It
is presentation with no text: a number creeping towards a ceiling is not something anybody
can act on, and putting "loading" into the one channel that cannot be skimmed on every
round trip would be noise.

Nothing here is a live region, and that is the point — the component exposes no
`aria-live`, no `role="status"` and no ARIA state, so it adds nothing to what a screen
reader already gets from the page change itself.

[reading-progress](/components/reading) makes the opposite call and exposes
`role="progressbar"` with `aria-valuenow` — because how far through an article you are *is*
something a reader acts on.

Under `prefers-reduced-motion: reduce` the bar keeps the report and loses the travel:
it still appears, and still says when the waiting ended, it just stops sliding to say
it. Removing the indicator entirely would take the information away from the readers
most likely to want it.

It never prints. A `position: fixed` strip does not scroll with the page when the page
becomes sheets — it lands on the text of whichever sheet it fell on.

## Keyboard Interaction

None. The bar reports; it is not a control, it is not focusable, and it sets
`pointer-events: none` so it never intercepts a click on whatever sits under it.

## Customization

Two token groups, both settable in your `:root {}`:

```css
:root {
    /* thickness per size */
    --page-progress-height-sm: 2px;
    --page-progress-height-md: 3px;
    --page-progress-height-lg: 5px;

    /* retint every bar on the page without touching a call site */
    --page-progress-fill: var(--color-wk-accent);
}
```
