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.
Preview
Sidebar
20% to 50%
Main content
Takes what is left.
{{-- 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
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:
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.
-
resources/views/components/ui/resizable.blade.php -
resources/views/components/ui/resizable/panel.blade.php -
resources/views/components/ui/resizable/handle.blade.php -
resources/js/ui/resizable.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: 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 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
@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>
@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>
@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>
/**
* 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