Skip to content
Brok UI

Loading…

No results

Scroll Area

Open source Core

A scrollable region with a custom, themeable scrollbar.

Version
v1.1.2
Stability
stable
License
MIT
Related
Bleed
Scroll Fade
Resizable

Preview

Tags

tags v1.0.0
tags v1.1.0
tags v1.2.0
tags v1.3.0
tags v1.4.0
tags v1.5.0
tags v1.6.0
tags v1.7.0
tags v1.8.0
tags v1.9.0
tags v2.0.0
tags v2.1.0
tags v2.2.0
tags v2.3.0
tags v2.4.0
previews.components.scroll-area.default.blade.php 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>

Installation

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

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

  • blade resources/views/components/ui/scroll-area.blade.php
  • blade resources/views/components/ui/scroll-area/viewport.blade.php
  • blade resources/views/components/ui/scroll-area/scrollbar.blade.php
  • blade resources/views/components/ui/scroll-area/thumb.blade.php
  • js resources/js/ui/scroll-area.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-area.md
# 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

manifest knowledge + registry-derived coverage

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.

scroll-area scroll-area-scrollbar scroll-area-thumb scroll-area-viewport

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

Layout and content structure

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
root scroll-area-scrollbar scroll-area-thumb scroll-area-viewport
Theming hooks
scroll-area scroll-area-scrollbar scroll-area-thumb scroll-area-viewport

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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-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.

resources/views/components/ui/scroll-area.blade.php Blade
{{-- 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>
resources/views/components/ui/scroll-area/viewport.blade.php Blade
<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>
resources/views/components/ui/scroll-area/scrollbar.blade.php Blade
{{-- 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>
resources/views/components/ui/scroll-area/thumb.blade.php Blade
{{-- 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>
resources/js/ui/scroll-area.js JS
/**
 * 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