Skip to main content
WireKit
Copy for LLM

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

Horizontal Resizable Panels
Left Panel — drag the centered grip between panels to resize.
Right Panel — automatically absorbs the remaining space.

Vertical Layout

Switch the direction to vertical to stack panels and drag the horizontal divider between them.

Vertical Resizable Panels
Top Panel — drag the centered grip on the horizontal divider.
Bottom Panel — fills the remaining height.

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.

Three-Panel Resizable Layout
Sidebar — drag the grip on the first divider.
Main Content — drag the grip on the second divider.
Inspector — fills the remaining space, but never shrinks below 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 element
  • aria-orientation perpendicular to the layout axis (a horizontal-layout split gets a vertical separator, and vice versa)
  • aria-controls referencing the controlled (previous) panel by id
  • aria-valuemin, aria-valuemax, aria-valuenow in percent
  • tabindex="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

Was this page helpful?

Thanks — that helps.

Voting requires cookies or local storage. What we store