Skip to content
Brok UI

Loading…

No results

Resizable

Open source

Resizable panel groups: any number of panels with default, min and max sizes, hairline handles with a grip that drag, take Arrow keys and reset on double-click.

Version
v1.2.1
Stability
stable
License
MIT
Related
Sidebar
Scroll Area
Bento Grid

Preview

Sidebar

20% to 50%

Main content

Takes what is left.

previews.components.resizable.default.blade.php Blade
{{-- Give the group a height; panels declare their share and limits. Drag the
     handle, use Arrow keys (Shift for 10%), or double-click it to reset. --}}
<x-ui.resizable class="h-56 w-full max-w-2xl rounded-lg border border-border bg-card">
    <x-ui.resizable.panel :default-size="30" :min-size="20" :max-size="50" class="p-4">
        <p class="text-sm font-medium text-foreground">{{ __('Sidebar') }}</p>
        <p class="mt-1 text-sm text-muted-foreground">{{ __('20% to 50%') }}</p>
    </x-ui.resizable.panel>
    <x-ui.resizable.handle />
    <x-ui.resizable.panel class="p-4">
        <p class="text-sm font-medium text-foreground">{{ __('Main content') }}</p>
        <p class="mt-1 text-sm text-muted-foreground">{{ __('Takes what is left.') }}</p>
    </x-ui.resizable.panel>
</x-ui.resizable>

Installation

terminal
php artisan ui:add resizable

Note

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

resources/js/ui/index.js JS
import './resizable.js';

Registry contract

php artisan ui:add resizable 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/resizable.blade.php
  • blade resources/views/components/ui/resizable/panel.blade.php
  • blade resources/views/components/ui/resizable/handle.blade.php
  • js resources/js/ui/resizable.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.

resizable.md
# Brok UI: Resizable (`resizable`)

Resizable panel groups: any number of panels with default, min and max sizes, hairline handles with a grip that drag, take Arrow keys and reset on double-click.

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

## Install

```bash
php artisan ui:add resizable
```

## Usage

```blade
{{-- Give the group a height; panels declare their share and limits. Drag the
     handle, use Arrow keys (Shift for 10%), or double-click it to reset. --}}
<x-ui.resizable class="h-56 w-full max-w-2xl rounded-lg border border-border bg-card">
    <x-ui.resizable.panel :default-size="30" :min-size="20" :max-size="50" class="p-4">
        <p class="text-sm font-medium text-foreground">{{ __('Sidebar') }}</p>
        <p class="mt-1 text-sm text-muted-foreground">{{ __('20% to 50%') }}</p>
    </x-ui.resizable.panel>
    <x-ui.resizable.handle />
    <x-ui.resizable.panel class="p-4">
        <p class="text-sm font-medium text-foreground">{{ __('Main content') }}</p>
        <p class="mt-1 text-sm text-muted-foreground">{{ __('Takes what is left.') }}</p>
    </x-ui.resizable.panel>
</x-ui.resizable>
```

## Props

- `direction` (string, default `horizontal`) — Split axis: horizontal (panels side by side) or vertical (panels stacked).
- `persist` (string|null, default `null`) — localStorage key that remembers the panel sizes after a drag, key step or reset; saved sizes are restored only when they still fit the panels' limits, and blocked storage keeps the defaults.
- `index` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `defaultSize` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `minSize` (int, default `10`) — Declared by @props in the registry Blade source.
- `maxSize` (int, default `90`) — Declared by @props in the registry Blade source.
- `withHandle` (bool, default `true`) — Declared by @props in the registry Blade source.
- `label` (mixed|null, default `null`) — Declared by @props in the registry Blade source.

## Use when

- Use to structure hierarchy, spacing, and responsiveness so content is easier to scan and navigate.
- Letting users adjust the relative size of side-by-side panels, such as a sidebar and main content, or code and preview.
- Needing any number of panels split by draggable handles with min/max size constraints.

## Avoid when

- Do not let layout primitives substitute for semantics, headings, or interaction rules users still need.
- The layout has a fixed split that users should not adjust; use a plain grid or flex layout instead.
- Panels should collapse to full-screen sheets on small viewports rather than stay resizable; compose a responsive layout instead.

## Anti-patterns

- Using visual layout as a substitute for semantic structure

## Rules

- Use the `<brok:resizable>` tag (or `<x-ui.resizable>`) 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/resizable
- Registry JSON (files, props, contract): https://brokui.dev/r/open/resizable.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

three.blade.php Blade
{{-- Three panels and two handles, one of them vertical inside the middle panel. --}}
<x-ui.resizable class="h-64 w-full max-w-2xl rounded-lg border border-border bg-card">
    <x-ui.resizable.panel :default-size="25" :min-size="15" class="p-4 text-sm text-foreground">{{ __('Files') }}</x-ui.resizable.panel>
    <x-ui.resizable.handle />
    <x-ui.resizable.panel :min-size="30" class="p-0">
        <x-ui.resizable direction="vertical" class="h-full">
            <x-ui.resizable.panel :default-size="65" class="p-4 text-sm text-foreground">{{ __('Editor') }}</x-ui.resizable.panel>
            <x-ui.resizable.handle />
            <x-ui.resizable.panel class="p-4 text-sm text-muted-foreground">{{ __('Terminal') }}</x-ui.resizable.panel>
        </x-ui.resizable>
    </x-ui.resizable.panel>
    <x-ui.resizable.handle />
    <x-ui.resizable.panel :default-size="25" :min-size="15" class="p-4 text-sm text-foreground">{{ __('Inspector') }}</x-ui.resizable.panel>
</x-ui.resizable>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
direction string horizontal Split axis: horizontal (panels side by side) or vertical (panels stacked).
persist string | null null localStorage key that remembers the panel sizes after a drag, key step or reset; saved sizes are restored only when they still fit the panels' limits, and blocked storage keeps the defaults.
index mixed | null null Declared by @props in the registry Blade source.
defaultSize mixed | null null Declared by @props in the registry Blade source.
minSize int 10 Declared by @props in the registry Blade source.
maxSize int 90 Declared by @props in the registry Blade source.
withHandle bool true Declared by @props in the registry Blade source.
label mixed | null null Declared by @props in the registry Blade source.

Slots

  • default — resizable.panel and resizable.handle parts composing the group.
  • x-ui.resizable.panel — Installed subcomponent from the registry item.
  • x-ui.resizable.handle — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

resizable resizable-handle resizable-panel

Behavior

  • Handles read the panels from the DOM, so any number of panels can be composed and resized against each other.
  • A handle can be dragged, moved with arrow keys (Home/End jump to the limits), or double-clicked to restore both adjacent panels to their default sizes.
  • The group needs an explicit height set by the consumer (for example class="h-64"); panels fill it.
  • 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.
  • Letting users adjust the relative size of side-by-side panels, such as a sidebar and main content, or code and preview.
  • Needing any number of panels split by draggable handles with min/max size constraints.

Avoid when

  • Do not let layout primitives substitute for semantics, headings, or interaction rules users still need.
  • The layout has a fixed split that users should not adjust; use a plain grid or flex layout instead.
  • Panels should collapse to full-screen sheets on small viewports rather than stay resizable; compose a responsive layout instead.

Use instead

  • Semantic HTML with standard flow

Anti-patterns

  • Using visual layout as a substitute for semantic structure
Anatomy
root resizable-handle resizable-panel
Theming hooks
resizable resizable-handle resizable-panel

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter ArrowUp ArrowDown ArrowLeft ArrowRight Home End
Focus
managed
  • Handles are focusable and support arrow-key resizing (with Home/End for the limits), so the split is adjustable without a pointer.
  • The grip is drawn with the control boundary token (border-input, at least 3:1 against the background in light and dark) and muted-foreground dots, so the handle stays findable while the hairline itself stays a decorative border; hover, focus and dragging switch the line and grip edge to the ring colour.
  • 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="resizable-{{ $record->id }}">
    {{-- Give the group a height; panels declare their share and limits. Drag the
         handle, use Arrow keys (Shift for 10%), or double-click it to reset. --}}
    <x-ui.resizable class="h-56 w-full max-w-2xl rounded-lg border border-border bg-card">
        <x-ui.resizable.panel :default-size="30" :min-size="20" :max-size="50" class="p-4">
            <p class="text-sm font-medium text-foreground">{{ __('Sidebar') }}</p>
            <p class="mt-1 text-sm text-muted-foreground">{{ __('20% to 50%') }}</p>
        </x-ui.resizable.panel>
        <x-ui.resizable.handle />
        <x-ui.resizable.panel class="p-4">
            <p class="text-sm font-medium text-foreground">{{ __('Main content') }}</p>
            <p class="mt-1 text-sm text-muted-foreground">{{ __('Takes what is left.') }}</p>
        </x-ui.resizable.panel>
    </x-ui.resizable>
</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/resizable.blade.php Blade
@props([
    'direction' => 'horizontal',
    // localStorage key that remembers the sizes; null keeps them for the page only.
    'persist' => null,
])

{{--
    Resizable — a row (or column) of panels split by draggable handles. Each
    panel declares its share with `default-size` (percent) and optional
    `min-size` / `max-size`; the handles read the panels from the DOM, so any
    number of panels works. Drag a handle, use Arrow keys (Home/End to the
    limits), or double-click it to restore the defaults. A `persist` key
    remembers the sizes in localStorage. Give the group its
    height (e.g. `class="h-64"`); panels fill it.
--}}
<div
    x-data="uiResizable({ direction: @js($direction === 'vertical' ? 'vertical' : 'horizontal'), persist: @js(filled($persist) ? (string) $persist : null) })"
    x-ref="group"
    data-slot="resizable"
    data-direction="{{ $direction === 'vertical' ? 'vertical' : 'horizontal' }}"
    {{ $attributes->merge(['class' => 'flex min-h-0 min-w-0 w-full overflow-hidden data-[direction=vertical]:flex-col']) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/resizable/panel.blade.php Blade
@props([
    'index' => null,
    // Percent of the group. Panels without one share what is left equally.
    'defaultSize' => null,
    'minSize' => 10,
    'maxSize' => 90,
])

<div
    data-slot="resizable-panel"
    @if ($defaultSize !== null) data-default-size="{{ (float) $defaultSize }}" @endif
    data-min-size="{{ (float) $minSize }}"
    data-max-size="{{ (float) $maxSize }}"
    x-data="{ i: 0 }"
    x-init="i = panelIndex($el)"
    :style="direction === 'vertical' ? `height:${sizes[i] ?? 0}%` : `width:${sizes[i] ?? 0}%`"
    {{ $attributes->merge(['class' => 'min-h-0 min-w-0 shrink-0 grow-0 overflow-auto']) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/resizable/handle.blade.php Blade
@props([
    // Show the grip pill in the middle of the hairline.
    'withHandle' => true,
    // Accessible name of the separator; null uses "Resize panels".
    'label' => null,
])

{{-- A hairline with an 8px hit area either side; the grip marks the middle.
     The grip's edge uses the control boundary token (border-input, 3:1
     against the page) and its dots the muted text colour, so it reads in
     light and dark. Hover, focus and dragging light the line and the grip
     edge in the ring colour and darken the dots. --}}
<div
    data-slot="resizable-handle"
    role="separator"
    tabindex="0"
    aria-label="{{ filled($label) ? $label : __('Resize panels') }}"
    :aria-orientation="direction === 'vertical' ? 'horizontal' : 'vertical'"
    aria-valuemin="0"
    aria-valuemax="100"
    :aria-valuenow="Math.round(sizes[i] ?? 0)"
    x-data="{ i: 0 }"
    x-init="i = handleIndex($el)"
    @pointerdown="startDrag($event, i)"
    @keydown="onKey($event, i)"
    @dblclick="reset()"
    :data-direction="direction"
    :data-dragging="dragging === i || null"
    {{ $attributes->merge(['class' => 'group relative z-10 flex shrink-0 touch-none select-none items-center justify-center self-stretch outline-none data-[direction=horizontal]:w-2 data-[direction=horizontal]:-mx-1 data-[direction=horizontal]:cursor-col-resize data-[direction=vertical]:h-2 data-[direction=vertical]:-my-1 data-[direction=vertical]:cursor-row-resize']) }}
>
    {{-- The visible hairline --}}
    <span
        aria-hidden="true"
        class="absolute bg-border transition-colors duration-150 group-hover:bg-ring group-focus-visible:bg-ring group-data-[dragging=true]:bg-ring group-data-[direction=horizontal]:inset-y-0 group-data-[direction=horizontal]:left-1/2 group-data-[direction=horizontal]:w-px group-data-[direction=horizontal]:-translate-x-1/2 group-data-[direction=vertical]:inset-x-0 group-data-[direction=vertical]:top-1/2 group-data-[direction=vertical]:h-px group-data-[direction=vertical]:-translate-y-1/2 motion-reduce:transition-none"
    ></span>
    @if ($withHandle)
        <span
            aria-hidden="true"
            class="relative flex items-center justify-center rounded-sm border border-input bg-background text-muted-foreground shadow-xs transition-colors group-hover:border-ring group-hover:text-foreground group-focus-visible:border-ring group-focus-visible:text-foreground group-data-[dragging=true]:border-ring group-data-[dragging=true]:text-foreground group-data-[direction=horizontal]:h-6 group-data-[direction=horizontal]:w-3 group-data-[direction=vertical]:h-3 group-data-[direction=vertical]:w-6 motion-reduce:transition-none"
        >
            <svg viewBox="0 0 8 16" fill="currentColor" class="h-4 w-2 shrink-0 group-data-[direction=vertical]:rotate-90"><circle cx="2" cy="3" r="1.25" /><circle cx="6" cy="3" r="1.25" /><circle cx="2" cy="8" r="1.25" /><circle cx="6" cy="8" r="1.25" /><circle cx="2" cy="13" r="1.25" /><circle cx="6" cy="13" r="1.25" /></svg>
        </span>
    @endif
</div>
resources/js/ui/resizable.js JS
/**
 * Resizable behavior — panels split by draggable handles.
 *
 * Panels and handles are read from the DOM at init, so any number of panels
 * works: handle `i` sits between panels `i` and `i + 1` and moves size between
 * those two only, within each panel's min/max. `sizes` holds percentages that
 * always sum to 100. Drag, Arrow keys (2% steps, Home/End to the limits) and
 * double-click (restore the defaults) all go through `move()`.
 *
 * With a `persist` key the sizes are written to localStorage after a drag, a
 * key step or a reset, and read back at init when they still fit the panels'
 * limits. Storage access is guarded: blocked storage keeps the defaults.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiResizable', (config = {}) => ({
        direction: config.direction ?? 'horizontal',
        persist: config.persist ?? null,
        sizes: [],
        defaults: [],
        limits: [],
        dragging: null,
        _move: null,
        _up: null,

        init() {
            this.$nextTick(() => this.readPanels());
        },

        destroy() {
            if (this._move) window.removeEventListener('pointermove', this._move);
            if (this._up) {
                window.removeEventListener('pointerup', this._up);
                window.removeEventListener('pointercancel', this._up);
            }
        },

        panels() {
            return [...this.$refs.group.querySelectorAll(':scope > [data-slot="resizable-panel"]')];
        },
        panelIndex(el) {
            return this.panels().indexOf(el);
        },
        handleIndex(el) {
            return [...this.$refs.group.querySelectorAll(':scope > [data-slot="resizable-handle"]')].indexOf(el);
        },

        /** Sizes from `data-default-size`; panels without one share the remainder. */
        readPanels() {
            const panels = this.panels();
            const declared = panels.map((p) => (p.dataset.defaultSize ? Number(p.dataset.defaultSize) : null));
            const used = declared.reduce((sum, v) => sum + (v ?? 0), 0);
            const free = declared.filter((v) => v === null).length;
            const share = free ? Math.max(0, 100 - used) / free : 0;
            this.limits = panels.map((p) => [Number(p.dataset.minSize || 0), Number(p.dataset.maxSize || 100)]);
            this.defaults = declared.map((v) => v ?? share);
            this.sizes = this.storedSizes() ?? [...this.defaults];
        },

        /** Saved sizes, only when they still match the panel count and limits. */
        storedSizes() {
            if (!this.persist) return null;
            let saved = null;
            try {
                saved = JSON.parse(window.localStorage.getItem(this.persist) ?? 'null');
            } catch {
                return null;
            }
            if (!Array.isArray(saved) || saved.length !== this.limits.length) return null;
            const fits = saved.every((v, i) => Number.isFinite(v) && v >= this.limits[i][0] - 0.01 && v <= this.limits[i][1] + 0.01);
            const total = saved.reduce((sum, v) => sum + v, 0);
            return fits && Math.abs(total - 100) < 0.5 ? saved : null;
        },

        store() {
            if (!this.persist) return;
            try {
                window.localStorage.setItem(this.persist, JSON.stringify(this.sizes.map((v) => Math.round(v * 100) / 100)));
            } catch {
                // Private windows and blocked site data throw; the sizes still apply for this page.
            }
        },

        /** Move `delta` percent from panel i + 1 into panel i, within both panels' limits. */
        move(i, delta) {
            const j = i + 1;
            if (this.sizes[j] === undefined) return;
            const [minI, maxI] = this.limits[i];
            const [minJ, maxJ] = this.limits[j];
            const pair = this.sizes[i] + this.sizes[j];
            let next = Math.min(maxI, Math.max(minI, this.sizes[i] + delta));
            next = Math.min(pair - minJ, Math.max(pair - maxJ, next));
            const sizes = [...this.sizes];
            sizes[i] = next;
            sizes[j] = pair - next;
            this.sizes = sizes;
        },

        reset() {
            this.sizes = [...this.defaults];
            this.store();
        },

        startDrag(e, i) {
            e.preventDefault();
            const rect = this.$refs.group.getBoundingClientRect();
            const rtl = getComputedStyle(this.$refs.group).direction === 'rtl';
            const total = this.direction === 'vertical' ? rect.height : rect.width;
            const start = this.direction === 'vertical' ? e.clientY : e.clientX;
            const startSizes = [...this.sizes];
            this.dragging = i;
            this._move = (ev) => {
                const now = this.direction === 'vertical' ? ev.clientY : ev.clientX;
                let delta = ((now - start) / total) * 100;
                if (this.direction !== 'vertical' && rtl) delta = -delta;
                this.sizes = [...startSizes];
                this.move(i, delta);
            };
            this._up = () => {
                this.dragging = null;
                this.store();
                window.removeEventListener('pointermove', this._move);
                window.removeEventListener('pointerup', this._up);
                window.removeEventListener('pointercancel', this._up);
            };
            window.addEventListener('pointermove', this._move);
            window.addEventListener('pointerup', this._up);
            window.addEventListener('pointercancel', this._up);
        },

        onKey(e, i) {
            const rtl = getComputedStyle(this.$refs.group).direction === 'rtl';
            const step = e.shiftKey ? 10 : 2;
            let handled = true;
            switch (e.key) {
                case 'ArrowLeft': this.move(i, rtl ? step : -step); break;
                case 'ArrowRight': this.move(i, rtl ? -step : step); break;
                case 'ArrowUp': this.move(i, -step); break;
                case 'ArrowDown': this.move(i, step); break;
                case 'Home': this.move(i, -100); break;
                case 'End': this.move(i, 100); break;
                case 'Enter': this.reset(); break;
                default: handled = false;
            }
            if (handled) {
                e.preventDefault();
                this.store();
            }
        },
    }));
});

Ownership & lifecycle

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