Resizable
The <x-wirekit::resizable> component creates split panel layouts where the user can drag the divider between panels to resize them. As of WireKit 0.2.1 it is a hybrid component: CSS drives the layout (flex, width/height, min/max, containment) and a tiny Alpine component (wirekitResizableHandle, ~170 lines, ~1 KB gzip) owns the interactive drag + keyboard control on the divider itself. The result is a centered grip affordance on every browser and full WAI-ARIA Window Splitter keyboard support.
Usage
Vertical Layout
Switch the direction to vertical to stack panels and drag the horizontal divider between them.
Vertical layouts need an explicit container height (e.g. style="height: 18rem;" or any Tailwind height utility) — without it the flex container collapses to its content and there is nothing to resize against.
Three-Panel Layout
You can chain any number of panels. Every panel except the last one becomes resizable; the last one always absorbs the remaining space.
minSize.
In layouts with three or more panels, always set minSize on the last panel too. It uses flex: 1 to absorb leftover space, but the Alpine handle treats that minimum as a hard floor when clamping a sibling drag — without it, dragging another handle can push the last panel below the width its content needs to render.
Vertical Mode Sizing
<x-wirekit::resizable direction="vertical"> carries contain: size in CSS, which means the wrapper's height does not flow from its descendants — an explicit external size is required for the panels' percent heights to resolve correctly. To prevent the component from collapsing to zero height when authors don't set one, the wrapper ships a default min-height: 16rem. Any explicit inline style="min-height: …" (or a parent container query, or a stylesheet rule with higher specificity) overrides this baseline:
{{-- Uses the 16rem default — renders visibly without extra styling --}}
<x-wirekit::resizable direction="vertical">…</x-wirekit::resizable>
{{-- Override the default for a taller panel stack --}}
<x-wirekit::resizable direction="vertical" style="min-height: 32rem;">…</x-wirekit::resizable>
Horizontal mode uses contain: inline-size instead, which only constrains width — height flows naturally from panel content, so no min-height baseline is needed there.
While dragging a handle, the component temporarily sets body { user-select: none } so the cursor crossing an adjacent panel doesn't highlight that panel's text mid-drag. The prior user-select value is captured at pointerdown and restored at pointerup/pointercancel — no leak even if the browser cancels the gesture.
How It Works
The layout is entirely CSS. Each non-last panel gets flex: 0 0 auto plus an inline width: var(--wk-default-size) (or height: in vertical mode) whose value comes from the defaultSize prop:
[data-wk-resizable][data-wk-direction="horizontal"]
> [data-wk-resizable-panel]:not(:last-child) {
flex: 0 0 auto;
width: var(--wk-default-size, 50%);
min-width: var(--wk-min-size, 10%);
max-width: var(--wk-max-size, 90%);
}
The handle sits between panels as a tinted 4 px divider with a centered grip pill. A small Alpine component (wirekitResizableHandle) attaches to each handle at alpine:init, reads the wrapper's direction and the controlled panel's min / max / default from data attributes, and sets up the WAI-ARIA Window Splitter attributes (role, aria-orientation, aria-valuemin/max/now, aria-controls, tabindex="0").
On pointer drag the component writes an inline width: XX% (or height: YY%) on the previous sibling panel; inline-style specificity wins over the CSS custom property so the drag takes immediate visual effect. On keyboard the same _setSize() helper is called from arrow-key / Home / End / Enter handlers.
Multi-panel clamping
In layouts with three or more panels, the handle can't just clamp the controlled panel to its own [minSize, maxSize] range — doing so would let a drag push the next-to-last panel past where the last panel's minimum starts, collapsing (or visually ejecting) the last panel. wirekitResizableHandle therefore re-computes an effective upper bound on every drag frame:
upper = 100% − (current size of every OTHER non-last panel)
− (last panel's declared minSize)
The drag position is measured panel-relative (event.clientX − panelRect.left / event.clientY − panelRect.top), so the handle always knows the controlled panel's current left/top edge regardless of how many siblings sit in front of it. Combined with the effective upper bound, this guarantees that no handle can ever overrun the space another panel has reserved, and the last panel always retains at least minSize room for its content.
Browser Support
Pure-CSS layout + a tiny Alpine component means the component works consistently on every browser in WireKit's supported baseline. The centered grip pill is CSS-drawn (positioned left: 50%; top: 50%; transform: translate(-50%, -50%)) so it looks identical in Chrome, Edge, Safari, and Firefox.
| Browser | Layout | Grip pill | Pointer drag | Keyboard |
|---|---|---|---|---|
| Chrome / Edge 111+ | Native CSS | Centered, themed | Pointer Events API | Full WAI-ARIA Window Splitter |
| Safari 16.4+ | Native CSS | Centered, themed | Pointer Events API | Full WAI-ARIA Window Splitter |
| Firefox 128+ | Native CSS | Centered, themed | Pointer Events API | Full WAI-ARIA Window Splitter |
Props
<x-wirekit::resizable>
| Prop | Type | Default | Description |
|---|---|---|---|
direction |
string |
'horizontal' |
Layout direction: 'horizontal' (side-by-side) or 'vertical' (stacked) |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::resizable.panel>
| Prop | Type | Default | Description |
|---|---|---|---|
defaultSize |
number |
50 |
Initial size as a percentage of the container (0–100). Also used as the value Enter / Space snaps the handle back to. |
minSize |
number |
10 |
Minimum allowed size in percent. Enforced by both CSS min-width/min-height and JS clamping. |
maxSize |
number |
90 |
Maximum allowed size in percent. Enforced by both CSS max-width/max-height and JS clamping. |
scope |
string|null |
null |
Scoped personalization key |
The last panel inside a <x-wirekit::resizable> always uses flex: 1 to absorb whatever space the others leave behind, so its defaultSize and maxSize have no effect. Its minSize IS honored, however — both as a CSS min-width / min-height floor and as the reserved slot the Alpine handle subtracts from every sibling drag. In three-or-more-panel layouts you should always give the last panel an explicit minSize so dragging a sibling handle can't collapse it below the width its content needs.
<x-wirekit::resizable.handle>
| Prop | Type | Default | Description |
|---|---|---|---|
index |
number |
0 |
Kept for backward compatibility — the Alpine component locates the controlled panel via previousElementSibling and no longer needs an ordinal. |
scope |
string|null |
null |
Scoped personalization key |
Sub-Components
| Component | Purpose |
|---|---|
resizable.panel |
A flex child whose initial size and min/max bounds drive the native layout and the JS clamp range |
resizable.handle |
Interactive divider + centered three-dot grip pill; keyboard-focusable, drag-enabled |
Accessibility
The hybrid implementation restores full keyboard support and brings the handle up to the WAI-ARIA Window Splitter pattern.
ARIA attributes (set by the Alpine component at init time, not in the Blade template):
role="separator"on the handle elementaria-orientationperpendicular to the layout axis (a horizontal-layout split gets averticalseparator, and vice versa)aria-controlsreferencing the controlled (previous) panel by idaria-valuemin,aria-valuemax,aria-valuenowin percenttabindex="0"so the handle is keyboard-reachable
Keyboard bindings:
| Key | Horizontal layout | Vertical layout |
|---|---|---|
ArrowLeft / ArrowRight |
Decrease / increase size by 1% | — |
ArrowUp / ArrowDown |
— | Decrease / increase size by 1% |
Shift + Arrow |
Step of 10% instead of 1% | Step of 10% instead of 1% |
Home |
Snap to minSize |
Snap to minSize |
End |
Snap to maxSize |
Snap to maxSize |
Enter / Space |
Reset to defaultSize |
Reset to defaultSize |
Focus styling: the handle gets a 2 px accent-colored outline on :focus-visible, and the grip pill adopts the accent-color border — the same focus treatment used by every other interactive WireKit component.
Pointer drag: the component calls setPointerCapture() on pointerdown so out-of-bounds drags (cursor leaves the handle while the button is held) still route events to the handle. On pointerup or pointercancel it releases capture cleanly. touch-action: none on the handle prevents the browser from hijacking the drag as a scroll gesture on touchscreen devices.
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move focus to a resize handle |
ArrowLeft / ArrowRight (horizontal handle) |
Move the handle by 1 step |
ArrowUp / ArrowDown (vertical handle) |
Move the handle by 1 step |
Shift+Arrow |
Larger step (10× by default) |
Home / End |
Move to the configured min / max |
Design Tokens
| Token | Used for |
|---|---|
--font-wk-sans |
Pane body font family |
--color-wk-border |
Splitter handle visible bar |
--border-wk-width |
Splitter handle bar thickness |
--radius-wk-lg |
Container border radius |
Personalization
Override classes globally via WireKit::personalize():
use Pushery\WireKit\WireKit;
WireKit::personalize('resizable', [
'base' => 'flex w-full overflow-hidden rounded-none border-0',
]);
WireKit::personalize('resizable.handle', [
'base' => 'shrink-0 self-stretch bg-blue-500',
]);
Further Reading
- WAI-ARIA Window Splitter Pattern — the ARIA pattern this component implements
- MDN: Pointer Events API — the drag mechanism the handle uses
- MDN:
setPointerCapture— how the handle keeps receiving events during out-of-bounds drags