Skip to content
Brok UI

Loading…

No results

Scroll Progress

Open source

A bar or ring that fills proportionally to how far the window or a target element has been scrolled.

Version
v1.1.5
Stability
stable
License
MIT
Related
Progress
Spinner
Circular Progress

Preview

Scroll the panel below to fill the ring and bar.

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%.

previews.components.scroll-progress.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/scroll-progress.blade.php
  • js resources/js/ui/scroll-progress.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

scroll-progress.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

scroll-progress scroll-progress-indicator scroll-progress-ring

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

Progress and sequence

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
root scroll-progress-ring scroll-progress-indicator
Theming hooks
scroll-progress scroll-progress-indicator scroll-progress-ring

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/scroll-progress.blade.php Blade
@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>
resources/js/ui/scroll-progress.js JS
/**
 * 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