Scroll Area
A scrollable region with a custom, themeable scrollbar.
Preview
<x-ui.scroll-area class="h-48 w-64 rounded-md border border-border">
<x-ui.scroll-area.viewport class="p-4">
<h4 class="mb-4 text-sm font-medium leading-none">{{ __('Tags') }}</h4>
<div class="text-sm">{{ __('tags v1.0.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.1.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.2.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.3.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.4.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.5.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.6.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.7.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.8.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.9.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.0.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.1.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.2.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.3.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.4.0') }}</div>
</x-ui.scroll-area.viewport>
<x-ui.scroll-area.scrollbar>
<x-ui.scroll-area.thumb />
</x-ui.scroll-area.scrollbar>
</x-ui.scroll-area>
Installation
php artisan ui:add scroll-area
Note
This component ships an Alpine behavior module at
resources/js/ui/scroll-area.js. Import it once from your bundle so it registers on alpine:init:
import './scroll-area.js';
Registry contract
php artisan ui:add scroll-area
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-area.blade.php -
resources/views/components/ui/scroll-area/viewport.blade.php -
resources/views/components/ui/scroll-area/scrollbar.blade.php -
resources/views/components/ui/scroll-area/thumb.blade.php -
resources/js/ui/scroll-area.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 Area (`scroll-area`)
A scrollable region with a custom, themeable scrollbar.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add scroll-area
```
## Usage
```blade
<x-ui.scroll-area class="h-48 w-64 rounded-md border border-border">
<x-ui.scroll-area.viewport class="p-4">
<h4 class="mb-4 text-sm font-medium leading-none">{{ __('Tags') }}</h4>
<div class="text-sm">{{ __('tags v1.0.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.1.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.2.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.3.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.4.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.5.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.6.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.7.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.8.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.9.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.0.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.1.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.2.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.3.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.4.0') }}</div>
</x-ui.scroll-area.viewport>
<x-ui.scroll-area.scrollbar>
<x-ui.scroll-area.thumb />
</x-ui.scroll-area.scrollbar>
</x-ui.scroll-area>
```
## Use when
- Use to structure hierarchy, spacing, and responsiveness so content is easier to scan and navigate.
- Bounding a block of content (a list, a code sample, a long menu) to a fixed height with a themeable, overlay-style scrollbar.
- Wanting native scroll and keyboard behavior while hiding the browser's default scrollbar chrome.
## Avoid when
- Do not let layout primitives substitute for semantics, headings, or interaction rules users still need.
- The content should scroll horizontally within a padded parent; use bleed with scroll instead.
- The page itself should scroll normally; scroll-area is for a bounded region, not the whole viewport.
## Anti-patterns
- Using visual layout as a substitute for semantic structure
## Rules
- Use the `<brok:scroll-area>` tag (or `<x-ui.scroll-area>`) 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-area
- Registry JSON (files, props, contract): https://brokui.dev/r/open/scroll-area.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
No documented props. Pass normal Blade attributes through the component root.
Slots
default— Scrollable content, typically composed with the viewport, scrollbar, and thumb parts.x-ui.scroll-area.viewport— Installed subcomponent from the registry item.x-ui.scroll-area.scrollbar— Installed subcomponent from the registry item.x-ui.scroll-area.thumb— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- The native browser scrollbar is hidden visually, but native scrolling and keyboard interaction remain fully functional.
- A custom thumb mirrors the native scroll position; its height reflects the visible-to-total content ratio and its position tracks scroll progress.
- The scrollbar track fades in while the pointer is over the area, briefly after scrolling, and while the thumb is being dragged, then fades out when idle.
- Clicking the track pages the viewport toward the click point; dragging the thumb scrolls directly.
- 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
Structure content hierarchy and responsive relationships.
Use when
- Use to structure hierarchy, spacing, and responsiveness so content is easier to scan and navigate.
- Bounding a block of content (a list, a code sample, a long menu) to a fixed height with a themeable, overlay-style scrollbar.
- Wanting native scroll and keyboard behavior while hiding the browser's default scrollbar chrome.
Avoid when
- Do not let layout primitives substitute for semantics, headings, or interaction rules users still need.
- The content should scroll horizontally within a padded parent; use bleed with scroll instead.
- The page itself should scroll normally; scroll-area is for a bounded region, not the whole viewport.
Use instead
- Semantic HTML with standard flow
Anti-patterns
- Using visual layout as a substitute for semantic structure
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
managed
- The viewport stays natively scrollable and keyboard-focusable (tabindex="0"), so keyboard and assistive-tech users are unaffected by the custom scrollbar visuals.
- The custom scrollbar and thumb are decorative (aria-hidden) and never the only way to scroll the content.
- 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-area-{{ $record->id }}">
<x-ui.scroll-area class="h-48 w-64 rounded-md border border-border">
<x-ui.scroll-area.viewport class="p-4">
<h4 class="mb-4 text-sm font-medium leading-none">{{ __('Tags') }}</h4>
<div class="text-sm">{{ __('tags v1.0.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.1.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.2.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.3.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.4.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.5.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.6.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.7.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.8.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v1.9.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.0.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.1.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.2.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.3.0') }}</div>
<div class="mt-3 text-sm">{{ __('tags v2.4.0') }}</div>
</x-ui.scroll-area.viewport>
<x-ui.scroll-area.scrollbar>
<x-ui.scroll-area.thumb />
</x-ui.scroll-area.scrollbar>
</x-ui.scroll-area>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- Scroll Area: native scrolling with an overlay scrollbar drawn by the
scrollbar/thumb parts. The bar shows while the pointer is over the area or
the content is scrolling, and fades when idle; the thumb drags. --}}
<div
x-data="uiScrollArea()"
data-slot="scroll-area"
:data-active="active || null"
@pointerenter="active = true"
@pointerleave="!dragging && (active = false)"
{{ $attributes->merge(['class' => 'group/scroll-area relative overflow-hidden']) }}
>{{ $slot }}</div>
<div data-slot="scroll-area-viewport" x-ref="viewport" @scroll="onScroll()" x-init="onScroll()" tabindex="0" {{ $attributes->merge(['class' => 'h-full w-full overflow-y-auto pe-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring']) }}>{{ $slot }}</div>
{{-- The track: inset from the corners, shown while the area is active (hovered,
scrolling or dragging) and hidden when the content fits. Clicking the track
pages the viewport toward the click. --}}
<div
data-slot="scroll-area-scrollbar"
aria-hidden="true"
x-show="thumbHeight < 100"
x-ref="scrollbar"
@pointerdown.self="pageTo($event)"
{{ $attributes->merge(['class' => 'absolute inset-y-1 end-0.5 w-2 rounded-full p-px opacity-0 transition-opacity duration-150 group-hover/scroll-area:opacity-100 group-data-[active=true]/scroll-area:opacity-100 motion-reduce:transition-none']) }}
>{{ $slot }}</div>
{{-- Absolutely positioned inside the (absolute) scrollbar track. `height` reflects the
visible/total ratio and `top` is the scroll progress along the remaining track space,
so the thumb both shrinks and travels as the viewport scrolls. Drag it to scroll. --}}
<div
data-slot="scroll-area-thumb"
:style="`height:${thumbHeight}%; top:${thumbTop}%`"
@pointerdown.stop="startDrag($event)"
{{ $attributes->merge(['class' => 'absolute inset-x-px min-h-6 cursor-default rounded-full bg-foreground/25 transition-colors hover:bg-foreground/40 data-[dragging=true]:bg-foreground/50 motion-reduce:transition-none']) }}
:data-dragging="dragging || null"
></div>
/**
* Scroll Area behavior.
*
* Renders an overlay scrollbar thumb that mirrors the native scroll position of a
* viewport. Native scrolling and keyboard interaction stay fully functional —
* the native scrollbar is only hidden visually. The bar is `active` (shown)
* while the pointer is over the area, for a moment after each scroll, and while
* the thumb is being dragged; a click on the track pages toward the click.
*
* `thumbHeight` is the visible-to-total ratio (as a percentage of the track) and
* `thumbTop` is how far the thumb has travelled down the remaining track space.
*
* Self-registers on `alpine:init` so import order does not matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiScrollArea', () => ({
thumbHeight: 100,
thumbTop: 0,
active: false,
dragging: false,
_idle: null,
_move: null,
_up: null,
onScroll() {
const v = this.$refs.viewport;
if (!v) {
return;
}
const ratio = v.scrollHeight > 0 ? v.clientHeight / v.scrollHeight : 1;
this.thumbHeight = Math.min(100, ratio * 100);
const maxScroll = v.scrollHeight - v.clientHeight;
this.thumbTop = maxScroll > 0
? (v.scrollTop / maxScroll) * (100 - this.thumbHeight)
: 0;
if (!isFinite(this.thumbTop)) {
this.thumbTop = 0;
}
// Show the bar while scrolling, then let it fade once the content settles.
this.active = true;
clearTimeout(this._idle);
this._idle = setTimeout(() => {
if (!this.dragging && !this.$root.matches(':hover')) this.active = false;
}, 800);
},
/** Drag the thumb: pointer travel maps onto scroll travel in the same ratio. */
startDrag(event) {
const v = this.$refs.viewport;
const track = this.$refs.scrollbar;
if (!v || !track) return;
event.preventDefault();
this.dragging = true;
this.active = true;
const startY = event.clientY;
const startTop = v.scrollTop;
const trackHeight = track.clientHeight;
this._move = (e) => {
const travel = (e.clientY - startY) / trackHeight;
v.scrollTop = startTop + travel * v.scrollHeight;
};
this._up = () => {
this.dragging = false;
document.removeEventListener('pointermove', this._move);
document.removeEventListener('pointerup', this._up);
document.removeEventListener('pointercancel', this._up);
if (!this.$root.matches(':hover')) this.active = false;
};
document.addEventListener('pointermove', this._move);
document.addEventListener('pointerup', this._up);
document.addEventListener('pointercancel', this._up);
},
/** A click on the track pages the viewport toward the click. */
pageTo(event) {
const v = this.$refs.viewport;
const track = this.$refs.scrollbar;
if (!v || !track) return;
const rect = track.getBoundingClientRect();
const thumbCentre = rect.top + (rect.height * (this.thumbTop + this.thumbHeight / 2)) / 100;
v.scrollBy({ top: event.clientY < thumbCentre ? -v.clientHeight : v.clientHeight, behavior: 'smooth' });
},
destroy() {
clearTimeout(this._idle);
if (this._move) document.removeEventListener('pointermove', this._move);
if (this._up) {
document.removeEventListener('pointerup', this._up);
document.removeEventListener('pointercancel', this._up);
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - 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