Skip to content
Brok UI

Loading…

No results

Scrub Bar

Open source

A seekable media scrubber: a token-styled track with a fill, drag thumb and optional time read-outs. Pointer + keyboard seek, RTL-aware, dispatches scrub/scrubstart/scrubend events (accepts scrubset for external control).

Version
v1.0.4
Stability
stable
License
MIT
Related
Slider
Progress
Audio Player

Preview

0:00
0:00
previews.components.scrub-bar.default.blade.php Blade
<div class="mx-auto w-full max-w-md space-y-6">
    {{-- Standalone scrubber with time read-outs (demo duration of 3:30). --}}
    <x-ui.scrub-bar :value="78" :duration="210" :times="true" />

    {{-- A thin, thumbless variant. --}}
    <x-ui.scrub-bar :value="140" :duration="210" :thumb="false" />
</div>

Installation

terminal
php artisan ui:add scrub-bar

Note

This component ships an Alpine behavior module at resources/js/ui/scrub-bar.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './scrub-bar.js';

Registry contract

php artisan ui:add scrub-bar 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/scrub-bar.blade.php
  • js resources/js/ui/scrub-bar.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.

scrub-bar.md
# Brok UI: Scrub Bar (`scrub-bar`)

A seekable media scrubber: a token-styled track with a fill, drag thumb and optional time read-outs. Pointer + keyboard seek, RTL-aware, dispatches scrub/scrubstart/scrubend events (accepts scrubset for external control).

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add scrub-bar
```

## Usage

```blade
<div class="mx-auto w-full max-w-md space-y-6">
    {{-- Standalone scrubber with time read-outs (demo duration of 3:30). --}}
    <x-ui.scrub-bar :value="78" :duration="210" :times="true" />

    {{-- A thin, thumbless variant. --}}
    <x-ui.scrub-bar :value="140" :duration="210" :thumb="false" />
</div>
```

## Props

- `value` (int, default `0`)
- `duration` (int, default `0`)
- `step` (int, default `5`)
- `thumb` (bool, default `true`)
- `times` (bool, default `false`)
- `label` (mixed|null, default `null`)

## Use when

- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Seeking inside audio or video with a track, fill and thumb.
- Reporting playback progress that the reader can also drag.

## Avoid when

- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Choosing a numeric value that is not a position in media; slider is the general control.
- Showing progress that cannot be seeked; progress is read-only.

## Anti-patterns

- Autoplay with sound
- Audio as the only access to essential information

## Rules

- Use the `<brok:scrub-bar>` tag (or `<x-ui.scrub-bar>`) 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/scrub-bar
- Registry JSON (files, props, contract): https://brokui.dev/r/open/scrub-bar.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
value int 0
duration int 0
step int 5
thumb bool true
times bool false
label mixed | null null

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

scrub-bar scrub-bar-duration scrub-bar-fill scrub-bar-thumb scrub-bar-time scrub-bar-track

Behavior

  • Pointer drag and keyboard both seek: arrow keys move by step, Home and End jump to the ends, and the arrows mirror under dir="rtl".
  • The bar dispatches scrubstart, scrub and scrubend events and accepts scrubset to be driven from outside.
  • times renders the current and total time read-outs beside the track.
  • 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

Audio and voice interaction

Play, inspect, record, or dictate temporal audio.

Use when

  • Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
  • Seeking inside audio or video with a track, fill and thumb.
  • Reporting playback progress that the reader can also drag.

Avoid when

  • Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
  • Choosing a numeric value that is not a position in media; slider is the general control.
  • Showing progress that cannot be seeked; progress is read-only.

Use instead

  • Silent scannable controls
  • Transcript or text input

Anti-patterns

  • Autoplay with sound
  • Audio as the only access to essential information
Anatomy
root scrub-bar-time scrub-bar-track scrub-bar-fill scrub-bar-thumb scrub-bar-duration
Theming hooks
scrub-bar scrub-bar-duration scrub-bar-fill scrub-bar-thumb scrub-bar-time scrub-bar-track

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
ArrowUp ArrowDown Home End PageUp PageDown
Focus
managed
  • The track is a role="slider" with aria-valuemin, aria-valuemax, aria-valuenow and a spoken time in aria-valuetext.
  • label names the slider, so it is never announced as an unnamed control.
  • 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="scrub-bar-{{ $record->id }}">
    <div class="mx-auto w-full max-w-md space-y-6">
        {{-- Standalone scrubber with time read-outs (demo duration of 3:30). --}}
        <x-ui.scrub-bar :value="78" :duration="210" :times="true" />
    
        {{-- A thin, thumbless variant. --}}
        <x-ui.scrub-bar :value="140" :duration="210" :thumb="false" />
    </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/scrub-bar.blade.php Blade
@props([
    'value' => 0,
    'duration' => 0,
    'step' => 5,
    'thumb' => true,
    'times' => false,
    'label' => null,
])

@php
    // A media scrubber: a seekable track with a fill, an optional drag thumb and
    // optional time read-outs. It is self-contained (own Alpine state) but wires
    // up cleanly as a controlled view:
    //   • it DISPATCHES `scrub` / `scrubstart` / `scrubend` (bubbling, detail.time)
    //     while the user drags or keys — a parent player listens for `scrub` and
    //     calls seek($event.detail.time);
    //   • it ACCEPTS `scrubset` on its root (detail {time, duration}) so the same
    //     parent can push playback progress back down each timeupdate.
    // Colour is 100% token-based; geometry uses logical properties so it mirrors
    // under dir="rtl" (pointer + arrow-key math invert with the writing direction).
    $config = [
        'value' => (float) $value,
        'duration' => max(0, (float) $duration),
        'step' => max(0.1, (float) $step),
    ];
    $ariaLabel = $label ?? __('Seek');
@endphp

<div
    data-slot="scrub-bar"
    x-data="uiScrubBar(@js($config))"
    @scrubset.stop="setExternal($event.detail)"
    {{ $attributes->merge(['class' => 'flex w-full items-center gap-3']) }}
>
    @if ($times)
        <span data-slot="scrub-bar-time" class="w-10 shrink-0 text-end text-xs tabular-nums text-muted-foreground" x-text="format(value)">0:00</span>
    @endif

    <div
        x-ref="track"
        data-slot="scrub-bar-track"
        role="slider"
        tabindex="0"
        aria-label="{{ $ariaLabel }}"
        :aria-valuemin="0"
        :aria-valuemax="Math.round(duration)"
        :aria-valuenow="Math.round(value)"
        :aria-valuetext="`${format(value)} / ${format(duration)}`"
        @pointerdown="onPointerDown($event)"
        @keydown="onKeydown($event)"
        :data-dragging="dragging ? 'true' : null"
        class="bg-secondary group relative h-2 w-full grow cursor-pointer touch-none rounded-full select-none transition-[height] duration-150 hover:h-3 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
    >
        {{-- Playback progress arrives a few times a second; between updates the
             fill and thumb glide (linear, one tick long) so the bar reads as a
             continuous motion. While the pointer drags, they follow it directly. --}}
        <div
            data-slot="scrub-bar-fill"
            class="bg-primary pointer-events-none absolute inset-y-0 start-0 rounded-full motion-reduce:transition-none"
            :class="dragging ? '' : 'transition-[inline-size] duration-250 ease-linear'"
            :style="`inline-size: ${progress}%`"
        ></div>
        @if ($thumb)
            <div
                data-slot="scrub-bar-thumb"
                class="bg-primary border-background pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-sm transition-[inset-inline-start,scale] duration-250 ease-linear rtl:translate-x-1/2 group-hover:scale-110 group-active:scale-125 motion-reduce:transition-none"
                :class="dragging ? '[transition-property:scale]' : ''"
                :style="`inset-inline-start: ${progress}%`"
            ></div>
        @endif
    </div>

    @if ($times)
        <span data-slot="scrub-bar-duration" class="w-10 shrink-0 text-xs tabular-nums text-muted-foreground" x-text="format(duration)">0:00</span>
    @endif
</div>
resources/js/ui/scrub-bar.js JS
/**
 * Scrub Bar behaviour — a seekable media track.
 *
 * Pointer or keyboard interaction maps a position on the track to a time in
 * [0, duration] and (a) updates local `value` so the fill/thumb track the
 * gesture live and (b) DISPATCHES bubbling events so an ancestor can react:
 *   • `scrubstart` — pointer/key interaction began
 *   • `scrub`      — new time chosen (detail: { time })
 *   • `scrubend`   — interaction finished
 * It also listens for a `scrubset` event on its own root (handled in Blade) so
 * a controller (e.g. audio-player) can push the real playback time back down.
 *
 * RTL-correct: the track's writing direction is read at gesture time and the
 * ratio is mirrored, so dragging/keys feel natural under dir="rtl".
 *
 * Teardown: the pointer drag attaches window listeners for the duration of the
 * drag and always removes them on pointerup; `destroy()` removes any that are
 * still attached if the component unmounts mid-drag.
 *
 * Self-registers on `alpine:init`. Plain JS — no Blade syntax.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiScrubBar', (config = {}) => ({
        value: Math.max(0, Number(config.value) || 0),
        duration: Math.max(0, Number(config.duration) || 0),
        step: Math.max(0.1, Number(config.step) || 5),

        dragging: false,
        _move: null,
        _up: null,

        get progress() {
            if (!this.duration) return 0;
            return Math.min(100, Math.max(0, (this.value / this.duration) * 100));
        },

        // mm:ss (or h:mm:ss) — defensive against NaN/Infinity from empty media.
        format(seconds) {
            if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
            const total = Math.floor(seconds);
            const h = Math.floor(total / 3600);
            const m = Math.floor((total % 3600) / 60);
            const s = total % 60;
            const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
            const ss = String(s).padStart(2, '0');
            return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
        },

        // Map a clientX to a time, mirroring the ratio under RTL.
        timeFromClientX(clientX) {
            const track = this.$refs.track;
            if (!track || !this.duration) return null;
            const rect = track.getBoundingClientRect();
            if (rect.width <= 0) return null;
            let ratio = (clientX - rect.left) / rect.width;
            if (getComputedStyle(track).direction === 'rtl') ratio = 1 - ratio;
            ratio = Math.min(1, Math.max(0, ratio));
            return this.duration * ratio;
        },

        commit(time, emit = true) {
            if (time == null) return;
            this.value = Math.min(this.duration, Math.max(0, time));
            if (emit) {
                this.$dispatch('scrub', { time: this.value });
            }
        },

        onPointerDown(event) {
            if (!this.duration || event.button === 2) return;
            event.preventDefault();
            this.$refs.track?.focus();
            this.$dispatch('scrubstart', {});
            this.dragging = true;
            this.commit(this.timeFromClientX(event.clientX));

            this._move = (e) => this.commit(this.timeFromClientX(e.clientX));
            this._up = () => {
                window.removeEventListener('pointermove', this._move);
                window.removeEventListener('pointerup', this._up);
                this._move = null;
                this._up = null;
                this.dragging = false;
                this.$dispatch('scrubend', {});
            };
            window.addEventListener('pointermove', this._move);
            window.addEventListener('pointerup', this._up, { once: true });
        },

        onKeydown(event) {
            if (!this.duration) return;
            const rtl = getComputedStyle(this.$refs.track).direction === 'rtl';
            const back = rtl ? 'ArrowRight' : 'ArrowLeft';
            const fwd = rtl ? 'ArrowLeft' : 'ArrowRight';
            let next = null;
            switch (event.key) {
                case back:
                case 'ArrowDown':
                    next = this.value - this.step;
                    break;
                case fwd:
                case 'ArrowUp':
                    next = this.value + this.step;
                    break;
                case 'Home':
                    next = 0;
                    break;
                case 'End':
                    next = this.duration;
                    break;
                case 'PageDown':
                    next = this.value - this.step * 2;
                    break;
                case 'PageUp':
                    next = this.value + this.step * 2;
                    break;
                default:
                    return;
            }
            event.preventDefault();
            this.$dispatch('scrubstart', {});
            this.commit(next);
            this.$dispatch('scrubend', {});
        },

        // External control: a parent pushes the authoritative time/duration down.
        // No `scrub` is re-emitted (emit=false) so we don't loop the controller.
        setExternal(detail = {}) {
            if (Number.isFinite(detail.duration)) {
                this.duration = Math.max(0, detail.duration);
            }
            if (Number.isFinite(detail.time)) {
                this.commit(detail.time, false);
            }
        },

        destroy() {
            this.dragging = false;
            if (this._move) window.removeEventListener('pointermove', this._move);
            if (this._up) window.removeEventListener('pointerup', this._up);
            this._move = null;
            this._up = null;
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.4
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