Scroll Progress
A bar or ring that fills proportionally to how far the window or a target element has been scrolled.
Preview
Paragraph 1 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 2 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 3 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 4 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 5 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 6 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 7 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 8 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 9 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 10 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 11 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
Paragraph 12 — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.
<div class="flex w-full max-w-md flex-col gap-6">
{{-- Circular ring tracking the scroll container below. --}}
<div class="flex items-center gap-3">
<x-ui.scroll-progress variant="circular" target="#scroll-progress-demo" />
<span class="text-muted-foreground text-sm">{{ __('Scroll the panel below to fill the ring and bar.') }}</span>
</div>
{{-- Static linear bar tied to the same scrollable container. --}}
<x-ui.scroll-progress variant="linear" position="static" target="#scroll-progress-demo" />
{{-- The scroll source. Tall content forces overflow. --}}
<div id="scroll-progress-demo" class="h-40 overflow-auto rounded-md border border-border p-4" tabindex="0" role="region" aria-label="Scrollable content">
<div class="space-y-4">
@foreach (range(1, 12) as $i)
<p class="text-muted-foreground text-sm">
{{ __('Paragraph') }} {{ $i }} — {{ __('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.') }}
</p>
@endforeach
</div>
</div>
</div>
Installation
php artisan ui:add scroll-progress
Note
This component ships an Alpine behavior module at
resources/js/ui/scroll-progress.js. Import it once from your bundle so it registers on alpine:init:
import './scroll-progress.js';
Registry contract
php artisan ui:add scroll-progress
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/scroll-progress.blade.php -
resources/js/ui/scroll-progress.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Scroll Progress (`scroll-progress`)
A bar or ring that fills proportionally to how far the window or a target element has been scrolled.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add scroll-progress
```
## Usage
```blade
<div class="flex w-full max-w-md flex-col gap-6">
{{-- Circular ring tracking the scroll container below. --}}
<div class="flex items-center gap-3">
<x-ui.scroll-progress variant="circular" target="#scroll-progress-demo" />
<span class="text-muted-foreground text-sm">{{ __('Scroll the panel below to fill the ring and bar.') }}</span>
</div>
{{-- Static linear bar tied to the same scrollable container. --}}
<x-ui.scroll-progress variant="linear" position="static" target="#scroll-progress-demo" />
{{-- The scroll source. Tall content forces overflow. --}}
<div id="scroll-progress-demo" class="h-40 overflow-auto rounded-md border border-border p-4" tabindex="0" role="region" aria-label="Scrollable content">
<div class="space-y-4">
@foreach (range(1, 12) as $i)
<p class="text-muted-foreground text-sm">
{{ __('Paragraph') }} {{ $i }} — {{ __('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.') }}
</p>
@endforeach
</div>
</div>
</div>
```
## Props
- `variant` (string, default `linear`) — Shape: linear (fill bar) or circular (ring with a percentage label).
- `target` (mixed|null, default `null`) — CSS selector for the scrollable element to track; null tracks the window/document.
- `position` (string, default `top`) — Linear variant only: top pins the bar fixed to the viewport edge; any other value renders it inline.
## Use when
- Use to show real progress, ordered stages, or process status when visibility helps completion.
- Showing how far a reader has scrolled through a long article, document, or page, as a fixed top bar or a circular ring.
- Tracking scroll of a specific scrollable element instead of the whole window via the target prop.
## Avoid when
- Do not add progress UI to tiny tasks or to flows whose steps are not meaningfully sequential.
- Showing a determinate percentage tied to a task or upload rather than scroll position; use progress instead.
- Needing an indeterminate busy indicator; use spinner or a loader component instead.
## Anti-patterns
- Decorative or inaccurate progress
- Steppers for nonsequential exploration
## Rules
- Use the `<brok:scroll-progress>` tag (or `<x-ui.scroll-progress>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/scroll-progress
- Registry JSON (files, props, contract): https://brokui.dev/r/open/scroll-progress.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | linear | Shape: linear (fill bar) or circular (ring with a percentage label). |
| target | mixed | null | null | CSS selector for the scrollable element to track; null tracks the window/document. |
| position | string | top | Linear variant only: top pins the bar fixed to the viewport edge; any other value renders it inline. |
Slots
default— Unused; the component has no visible default slot content.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- The linear variant defaults to a bar fixed to the top of the viewport; the circular variant renders an inline ring with a live percentage label.
- uiScrollProgress (scroll-progress.js) computes the percentage on scroll and drives the fill width or the ring's stroke-dashoffset.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Communicate genuine progress or ordered stages.
Use when
- Use to show real progress, ordered stages, or process status when visibility helps completion.
- Showing how far a reader has scrolled through a long article, document, or page, as a fixed top bar or a circular ring.
- Tracking scroll of a specific scrollable element instead of the whole window via the target prop.
Avoid when
- Do not add progress UI to tiny tasks or to flows whose steps are not meaningfully sequential.
- Showing a determinate percentage tied to a task or upload rather than scroll position; use progress instead.
- Needing an indeterminate busy indicator; use spinner or a loader component instead.
Use instead
- Simple completion text for tiny tasks
Anti-patterns
- Decorative or inaccurate progress
- Steppers for nonsequential exploration
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The root carries role="progressbar" with aria-valuemin/aria-valuemax and a live aria-valuenow tied to scroll percentage.
- Fill and ring transitions are disabled under prefers-reduced-motion.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="scroll-progress-{{ $record->id }}">
<div class="flex w-full max-w-md flex-col gap-6">
{{-- Circular ring tracking the scroll container below. --}}
<div class="flex items-center gap-3">
<x-ui.scroll-progress variant="circular" target="#scroll-progress-demo" />
<span class="text-muted-foreground text-sm">{{ __('Scroll the panel below to fill the ring and bar.') }}</span>
</div>
{{-- Static linear bar tied to the same scrollable container. --}}
<x-ui.scroll-progress variant="linear" position="static" target="#scroll-progress-demo" />
{{-- The scroll source. Tall content forces overflow. --}}
<div id="scroll-progress-demo" class="h-40 overflow-auto rounded-md border border-border p-4" tabindex="0" role="region" aria-label="Scrollable content">
<div class="space-y-4">
@foreach (range(1, 12) as $i)
<p class="text-muted-foreground text-sm">
{{ __('Paragraph') }} {{ $i }} — {{ __('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Keep scrolling to watch the progress indicators advance toward 100%.') }}
</p>
@endforeach
</div>
</div>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'variant' => 'linear',
'target' => null,
'position' => 'top',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
@endphp
{{--
Scroll Progress (spec §16B). Fill bar tied to scroll percentage.
`target` null = window/document scroll; otherwise a CSS selector for a
scrollable element. Behavior lives in uiScrollProgress (resources/js/ui).
--}}
<div
data-slot="scroll-progress"
role="progressbar"
aria-label="{{ __('Scroll progress') }}"
:aria-valuenow="Math.round(progress)"
aria-valuemin="0"
aria-valuemax="100"
x-data="uiScrollProgress({ target: @js($target) })"
@if ($variant === 'circular')
{{ $attributes->merge(['class' => 'inline-flex items-center justify-center']) }}
@elseif ($position === 'top')
{{ $attributes->merge(['class' => 'fixed inset-x-0 top-0 z-sticky h-1 bg-transparent']) }}
@else
{{ $attributes->merge(['class' => 'relative h-1 w-full overflow-hidden rounded-full bg-muted']) }}
@endif
>
@if ($variant === 'circular')
{{-- Ring: track (text-muted) + progress (text-primary) via stroke-dashoffset. --}}
<svg
data-slot="scroll-progress-ring"
{{-- 48px ring, 3px stroke: leaves a 36px well so "100%" sits clear of the track. --}}
class="size-12 -rotate-90 rtl:rotate-90"
viewBox="0 0 48 48"
fill="none"
aria-hidden="true"
>
<circle
class="text-muted"
cx="24"
cy="24"
r="21"
stroke="currentColor"
stroke-width="3"
/>
<circle
data-slot="scroll-progress-indicator"
class="text-primary transition-[stroke-dashoffset] duration-150 ease-out motion-reduce:transition-none"
cx="24"
cy="24"
r="21"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray="131.95"
:stroke-dashoffset="131.95 - (131.95 * progress) / 100"
/>
</svg>
<span
class="absolute text-2xs font-medium leading-none tabular-nums text-foreground"
x-text="`${Math.round(progress)}%`"
></span>
@else
{{-- Linear: fill width bound to progress %. --}}
<div
data-slot="scroll-progress-indicator"
class="h-full bg-primary transition-[width] duration-150 ease-out motion-reduce:transition-none @if ($position !== 'top') rounded-full @endif"
:style="`width: ${progress}%`"
></div>
@endif
</div>
/**
* Scroll Progress behavior (spec §16B).
*
* Tracks how far a scroll source has been scrolled and exposes `progress`
* (0..100). The source is either the window/document (default) or the element
* matched by the `target` CSS selector. Listeners are passive and torn down on
* destroy. Progress is recomputed on init so the first paint (SSR/hydration) is
* correct.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiScrollProgress', (config = {}) => ({
progress: 0,
target: config.target ?? null,
_source: null,
_onScroll: null,
_onResize: null,
init() {
this._source = this.target ? document.querySelector(this.target) : window;
if (!this._source) {
return;
}
this._onScroll = () => this.compute();
this._onResize = () => this.compute();
const scrollEl = this._source === window ? window : this._source;
scrollEl.addEventListener('scroll', this._onScroll, { passive: true });
window.addEventListener('resize', this._onResize, { passive: true });
this.compute();
},
compute() {
if (!this._source) {
return;
}
let scrollTop;
let scrollHeight;
let clientHeight;
if (this._source === window) {
const doc = document.documentElement;
scrollTop = window.scrollY ?? doc.scrollTop;
scrollHeight = doc.scrollHeight;
clientHeight = window.innerHeight;
} else {
scrollTop = this._source.scrollTop;
scrollHeight = this._source.scrollHeight;
clientHeight = this._source.clientHeight;
}
const scrollable = scrollHeight - clientHeight;
const pct = scrollable > 0 ? (scrollTop / scrollable) * 100 : 0;
this.progress = Math.min(100, Math.max(0, pct));
},
destroy() {
if (this._source) {
const scrollEl = this._source === window ? window : this._source;
scrollEl.removeEventListener('scroll', this._onScroll);
}
window.removeEventListener('resize', this._onResize);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.5 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0