---
title: Resizable
description: Resizable panel layout
visibility: guest
draft: false
---

# 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

:::preview{title="Horizontal Resizable Panels"}
<x-wirekit::resizable>
    <x-wirekit::resizable.panel :defaultSize="50" :minSize="20" :maxSize="80">
        <div style="padding: 1rem;">
            Left Panel — drag the centered grip between panels to resize.
        </div>
    </x-wirekit::resizable.panel>
    <x-wirekit::resizable.handle />
    <x-wirekit::resizable.panel>
        <div style="padding: 1rem;">
            Right Panel — automatically absorbs the remaining space.
        </div>
    </x-wirekit::resizable.panel>
</x-wirekit::resizable>
:::

### Vertical Layout

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

:::preview{title="Vertical Resizable Panels"}
<x-wirekit::resizable direction="vertical" style="height: 18rem;">
    <x-wirekit::resizable.panel :defaultSize="40" :minSize="20" :maxSize="80">
        <div style="padding: 1rem;">Top Panel — drag the centered grip on the horizontal divider.</div>
    </x-wirekit::resizable.panel>
    <x-wirekit::resizable.handle />
    <x-wirekit::resizable.panel>
        <div style="padding: 1rem;">Bottom Panel — fills the remaining height.</div>
    </x-wirekit::resizable.panel>
</x-wirekit::resizable>
:::

::: tip
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.

:::preview{title="Three-Panel Resizable Layout"}
<x-wirekit::resizable>
    <x-wirekit::resizable.panel :defaultSize="25" :minSize="15" :maxSize="40">
        <div style="padding: 1rem;">
            Sidebar — drag the grip on the first divider.
        </div>
    </x-wirekit::resizable.panel>
    <x-wirekit::resizable.handle />
    <x-wirekit::resizable.panel :defaultSize="50" :minSize="20" :maxSize="70">
        <div style="padding: 1rem;">
            Main Content — drag the grip on the second divider.
        </div>
    </x-wirekit::resizable.panel>
    <x-wirekit::resizable.handle />
    <x-wirekit::resizable.panel :minSize="15">
        <div style="padding: 1rem;">
            Inspector — fills the remaining space, but never shrinks below <code>minSize</code>.
        </div>
    </x-wirekit::resizable.panel>
</x-wirekit::resizable>
:::

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

```blade
{{-- 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:

```css
[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:

```text
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](../getting-started/integration.md#browser-support). 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](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) 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()`:

```php
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](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) — the ARIA pattern this component implements
- [MDN: Pointer Events API](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) — the drag mechanism the handle uses
- [MDN: `setPointerCapture`](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture) — how the handle keeps receiving events during out-of-bounds drags
