@data-slot/resizable
v1.0.2
Published
Headless resizable panel groups for vanilla JavaScript. Accessible, unstyled, tiny.
Maintainers
Readme
@data-slot/resizable
Headless resizable panel groups for vanilla JavaScript. Accessible, unstyled, tiny.
Inspired by react-resizable-panels so that shadcn/ui-style component libraries can use it as a drop-in primitive.
Installation
npm install @data-slot/resizableQuick Start
<div data-slot="resizable" data-direction="horizontal">
<div data-slot="resizable-panel" data-default-size="50" data-min-size="20">Left</div>
<div data-slot="resizable-handle" aria-label="Resize panels"></div>
<div data-slot="resizable-panel" data-default-size="50">Right</div>
</div>
<script type="module">
import { create } from "@data-slot/resizable";
const controllers = create();
</script>API
Initialization
create(scope?)
Auto-discover and bind all resizable groups in a scope (defaults to document).
import { create } from "@data-slot/resizable";
const controllers = create(); // Returns ResizableController[]createResizable(root, options?)
Create a controller for a specific element.
import { createResizable } from "@data-slot/resizable";
const resizable = createResizable(element, {
direction: "horizontal",
keyboardResizeBy: 10,
onLayoutChange: (layout) => console.log(layout),
});Slots
| Slot | Description |
| ---- | ----------- |
| resizable | Root flex container for the group |
| resizable-panel | A panel with a percentage size |
| resizable-handle | Focusable separator between adjacent panels |
<div data-slot="resizable">
<div data-slot="resizable-panel">A</div>
<div data-slot="resizable-handle" aria-label="Resize panels"></div>
<div data-slot="resizable-panel">B</div>
</div>A group needs at least one resizable-panel, and exactly one
resizable-handle between each adjacent pair of panes.
Data Attributes
Options can also be set via data attributes. JS options take precedence.
On the root:
| Attribute | Type | Default | Description |
| ------------------------- | ------ | ------------ | -------------------------------- |
| data-direction | string | horizontal | Layout axis |
| data-keyboard-resize-by | number | 10 | Percent moved per arrow keypress |
On each resizable-panel:
| Attribute | Type | Default | Description |
| --------------------- | ------- | ---------- | ----------------------- |
| data-default-size | number | even split | Initial size (%) |
| data-min-size | number | 0 | Minimum size (%) |
| data-max-size | number | 100 | Maximum size (%) |
| data-collapsible | boolean | false | Pane can collapse |
| data-collapsed-size | number | 0 | Size (%) when collapsed |
Panel sizes and constraints must be between 0 and 100. minSize must not exceed
maxSize, and a collapsible panel's collapsedSize must not exceed minSize.
The constraints must allow a layout totaling 100%; initialization throws if they
cannot. A collapsible panel can occupy its collapsed size or any size between
its minimum and maximum.
Options
| Option | Type | Default | Description |
| ------------------ | ---------------------------- | -------------- | -------------------------------- |
| direction | "horizontal" \| "vertical" | "horizontal" | Layout axis |
| keyboardResizeBy | number | 10 | Percent moved per arrow keypress |
| onLayoutChange | (layout: number[]) => void | undefined | Called when the layout changes |
Controller
Pane indices start at zero. setLayout() requires one finite, non-negative number
per panel and a positive total; it normalizes the values to 100% and applies the
panel constraints. Invalid layouts throw without changing the current layout.
resizePane() requires a finite size. Indexed mutation methods and getSize()
throw for an invalid pane index.
| Method/Property | Description |
| ------------------------- | ------------------------------------------------------ |
| layout | Current layout as number[] of percentages (readonly) |
| setLayout(sizes) | Set the full layout (validated/clamped) |
| resizePane(index, size) | Resize a pane to size% |
| collapse(index) | Collapse a collapsible pane |
| expand(index) | Expand a collapsed pane |
| isCollapsed(index) | Whether a pane is collapsed |
| isExpanded(index) | Whether a pane is expanded |
| getSize(index) | Current size (%) of a pane |
| destroy() | Cleanup all listeners and global styles |
Events
Outbound Events
element.addEventListener("resizable:change", (e) => {
console.log("Layout:", e.detail.layout);
});
element.addEventListener("resizable:dragging", (e) => {
console.log("Dragging:", e.detail.dragging);
});Inbound Events
| Event | Detail | Description |
| --------------- | ---------------------- | ------------------------------- |
| resizable:set | { layout: number[] } | Set the layout programmatically |
element.dispatchEvent(new CustomEvent("resizable:set", { detail: { layout: [30, 70] } }));Styling
The component sets flex styles on the root and panels directly. Give the group
a height and the handles a visible width or height. Use data-* attributes for
visual styling:
/* Style the handle */
[data-slot="resizable-handle"] {
width: 4px;
background: #ccc;
}
[data-slot="resizable"][data-direction="vertical"] [data-slot="resizable-handle"] {
width: 100%;
height: 4px;
}
/* Active drag / keyboard focus */
[data-slot="resizable-handle"][data-active] {
background: #2563eb;
}
/* Collapsed pane */
[data-slot="resizable-panel"][data-collapsed] {
opacity: 0;
}Add a CSS transition on flex-grow for animated collapse/expand:
[data-slot="resizable-panel"] {
transition: flex-grow 0.2s ease;
}Keyboard Navigation
Focus a handle with Tab before using these keys:
| Key | Action |
| --- | ------ |
| ArrowLeft / ArrowRight | Resize a horizontal group by keyboardResizeBy (10% by default) |
| ArrowUp / ArrowDown | Resize a vertical group by the same step |
| Shift + arrow key | Move to the limit in that direction |
| Home / End | Minimize / maximize the preceding panel within constraints |
| Enter | Toggle collapse of the preceding panel when it is collapsible |
| F6 / Shift + F6 | Focus the next / previous handle, wrapping within the group |
Accessibility
The component automatically handles:
role="separator"on each handlearia-orientation(perpendicular to the layout direction)aria-controlslinking each handle to its preceding panearia-valuemin/aria-valuemax/aria-valuenowreflecting live constraints- Unique ID generation for the root, panes, and handles
Give each handle an accessible name with aria-label or aria-labelledby.
Behavior
Switching input methods
Keyboard resizing and valid imperative layout updates end an active drag before applying the change. Subsequent pointer movement has no effect until a new drag starts. Only one group can drag at a time in each document.
All input methods remember a panel's size immediately before it collapses.
expand() and Enter restore that size, subject to the other panels' constraints.
An initially collapsed panel expands to its minimum size when no prior size exists.
Persisting Layout
There is no built-in persistence, but reading and restoring the split is a one-liner:
import { createResizable } from "@data-slot/resizable";
const el = document.querySelector('[data-slot="resizable"]');
const saved = localStorage.getItem("layout:sidebar");
const resizable = createResizable(el, {
onLayoutChange(layout) {
localStorage.setItem("layout:sidebar", JSON.stringify(layout));
},
});
if (saved) resizable.setLayout(JSON.parse(saved));License
MIT
