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).
Preview
<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
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:
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.
-
resources/views/components/ui/scrub-bar.blade.php -
resources/js/ui/scrub-bar.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: 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
@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>
/**
* 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