resizable
Three panes. Drag a divider, or focus one and arrow it along.
Files
Editor
Notes
import { resizable } from '@markless/ui';
// Nothing styles `resizable.item` from here: the family owns that element's `style`.
const group = 'display: flex; inline-size: min(32rem, 100%); block-size: 8rem; border: 2px solid var(--code-edge); border-radius: 6px; background: var(--paper); overflow: clip';
const divider = 'flex: none; inline-size: 6px; background: var(--code-edge); cursor: col-resize; touch-action: none';
const pane = 'margin: 0; padding: 0.6em 0.7em; font-weight: 700';
export default function Workspace() @{
<resizable.root defaultSizes={{ files: 28, editor: 44, notes: 28 }} style={group}>
<resizable.item value="files">
<p style={pane}>Files</p>
</resizable.item>
<resizable.thumb value="files" min={15} max={55} aria-label="Resize files" style={divider} />
<resizable.item value="editor">
<p style={pane}>Editor</p>
</resizable.item>
<resizable.thumb value="editor" min={20} max={70} aria-label="Resize editor" style={divider} />
<resizable.item value="notes">
<p style={pane}>Notes</p>
</resizable.item>
</resizable.root>
}
A panel is named by its value, and a divider names the panel it resizes. Sizes are shares of the
group from 0 to 100, keyed by those names, so one record covers a whole widget, nested groups
included. A divider only ever moves one pair: the panel it names and the panel after it exchange
the same number of points, so the group still adds up to what it added up to and nothing else on
the row shifts under you.
Anatomy
resizable.rootis the group, holding the sizes, the axis and the step.resizable.itemis one panel, named by itsvalue, which is also itsid.resizable.thumbis the divider, naming the panel it resizes and carrying that panel's limits.
Each panel publishes its share as a --size custom property, and the family lays the group out
itself under @layer markless. It writes ui-panels, ui-panel, ui-divider, ui-orientation,
ui-value, ui-size, ui-min, ui-max, ui-collapsible, ui-collapsed, ui-dragging and
ui-disabled as its state changes.
Stack them instead
Set orientation="vertical" and the panels sit one above the other, with the dividers running
across. The arrows follow the group: up and down move a divider in a vertical group, left and right
in a horizontal one.
<resizable.root orientation="vertical" defaultSizes={{ top: 60, bottom: 40 }}>
A panel that folds away
Give a divider collapsible and Enter folds the panel it names down to collapsedSize. The next
Enter puts back the size that panel measured before it folded, not the size it started the page
with.
<resizable.thumb value="files" min={15} max={55} collapsible collapsedSize={5} aria-label="Resize files" />
Hold the sizes yourself
Pass sizes and the widget is controlled: a gesture reports through onChange and nothing moves
until the new record comes back in. onChangeEnd says the change has settled, once the pointer is
released or the key that moved it. Leave sizes out, pass defaultSizes, and the widget keeps its
own record.
<resizable.root sizes={held} onChange={setHeld} onChangeEnd={save}>
Keyboard
Focus sits on a divider, and every key here moves the panel that divider names. In right-to-left text the two horizontal arrows swap.
| Key | What it does |
|---|---|
| Left arrow or Right arrow | Move the divider one step, in a group laid out side by side. |
| Up arrow or Down arrow | Move the divider one step, in a group laid out top to bottom. |
| Shift and an arrow | Move it ten steps at once. |
| Home | Take the panel down to the divider's min. |
| End | Take it up to the divider's max. |
| Enter | Fold the panel away, then put it back. Needs collapsible. |
| Escape | Abandon a drag in flight, putting back the sizes it started from. |
API
Every prop is optional unless its row says otherwise, and the types below are the family's own.
ResizableSizes is Record<string, number> and ResizableOrientation is
'horizontal' | 'vertical'.
resizable.root
| Prop | Type | What it does |
|---|---|---|
sizes |
ResizableSizes |
The sizes in force. Passing this makes the widget controlled: a gesture reports through onChange and nothing moves until the new record comes back in. |
defaultSizes |
ResizableSizes |
The sizes an uncontrolled widget starts with. A panel left out is an equal share. |
orientation |
ResizableOrientation |
Which axis the panels are laid along. Omit it and they sit side by side. |
step |
number |
How far one arrow key moves a divider, in points of the group. Defaults to 1; Shift multiplies it by ten. |
disabled |
boolean |
Nothing can be resized. |
onChange |
(sizes: ResizableSizes) => void |
Called with every panel's size each time one changes, including during a drag. |
onChangeEnd |
(sizes: ResizableSizes) => void |
Called once a change settles: the pointer released, or the key that moved it. |
| Attribute | Present when | What to key styling off |
|---|---|---|
ui-panels |
Always | This element is a group of panels. |
ui-orientation |
Always | horizontal or vertical, the axis the panels are laid along. |
ui-disabled |
disabled is set |
Nothing in here responds. |
ui-dragging |
A divider in this group is being dragged | The whole group is busy. |
| Selector | Default | Why |
|---|---|---|
[ui-panels] |
display: flex; flex-direction: row |
The group lays the panels along its axis, so nothing has to be told twice. |
[ui-panels][ui-orientation="vertical"] |
flex-direction: column |
The same group, stacked. |
Everything a <div> accepts reaches the element too, apart from onChange, which is the family's.
resizable.item
| Prop | Type | What it does |
|---|---|---|
value |
string |
Required. This panel's name. The sizes record is keyed by it and it is minted as the panel's id, so it must be unique in the page. |
orientation |
ResizableOrientation |
Set it to host a nested group inside this panel, laid along that axis. |
| Attribute | Present when | What to key styling off |
|---|---|---|
id |
Always | The panel's value, so a divider's aria-controls reaches here. Write your own id and you owe yourself the matching aria-controls. |
style |
A size is held for this panel | Carries --size, this panel's share of its group. The family owns this attribute, so style a panel from a stylesheet rather than a style prop. |
ui-panel |
Always | This element is a panel. |
ui-value |
Always | Holds this panel's name, so one selector can reach one panel. |
ui-orientation |
This panel hosts a nested group | The axis that nested group runs along. |
ui-size |
A size is held for this panel | The share, rounded to two decimals. |
ui-collapsed |
This panel is folded away | The folded look. |
| Selector | Default | Why |
|---|---|---|
[ui-panel] |
flex: var(--size, 1) 1 0; overflow: hidden; min-inline-size: 0; min-block-size: 0 |
The share is the grow factor, so a panel nobody has sized is an equal share. |
[ui-panel][ui-orientation="horizontal"] |
display: flex; flex-direction: row |
A panel hosting a nested group lays that group out. |
[ui-panel][ui-orientation="vertical"] |
display: flex; flex-direction: column |
The same, stacked. |
Everything a <div> accepts reaches the element too, apart from style.
resizable.thumb
| Prop | Type | What it does |
|---|---|---|
value |
string |
Required. The name of the primary panel: the one this divider resizes and reports. |
min |
number |
The smallest the primary panel may be, as a share of the group. Defaults to 0. |
max |
number |
The largest the primary panel may be. Defaults to 100. |
collapsible |
boolean |
Enter collapses the primary panel and restores it. |
collapsedSize |
number |
What the primary panel measures when collapsed. Defaults to 0. |
orientation |
ResizableOrientation |
The axis of the group this divider sits in. Omit it and the root's axis is used. |
| Attribute | Present when | What to key styling off |
|---|---|---|
role |
Always | separator, which is the window splitter a screen reader announces. |
tabindex |
Always | 0, or -1 while the group is disabled. |
aria-controls |
Always | The id of the panel this divider resizes. |
aria-orientation |
Always | The divider's own axis, which is the perpendicular of the group: panels side by side are parted by a vertical splitter. |
aria-valuemin, aria-valuemax |
Always | The min and max this divider was written with. |
aria-valuenow |
A size is held for the primary panel | That panel's share. |
aria-valuetext |
A size is held for the primary panel | The same share read as a percentage, so a reader says 28% rather than a bare decimal. |
aria-disabled |
Always | true or false. |
ui-divider |
Always | This element is a divider. |
ui-value |
Always | The name of the panel this divider resizes. |
ui-orientation |
Always | The axis of the group this divider sits in. |
ui-min, ui-max |
Always | The limits, for a stylesheet that wants them. |
ui-collapsible |
collapsible is set |
Enter folds this one. |
ui-collapsed |
The primary panel is folded away | The folded look. |
ui-dragging |
This divider is the one being dragged | The gripped look. |
ui-disabled |
The group is disabled | Nothing here responds. |
| Selector | Default | Why |
|---|---|---|
[ui-divider] |
flex: none; touch-action: none |
A divider belongs to nobody's share, and a drag along it must not scroll the page. |
[ui-divider][ui-orientation="horizontal"] |
cursor: col-resize |
The pointer says which way it moves. |
[ui-divider][ui-orientation="vertical"] |
cursor: row-resize |
The same, stacked. |
[ui-divider][ui-disabled] |
cursor: default |
Nothing to grip. |
It carries no name of its own, so write aria-label on every divider naming the panel it resizes:
a group with several of them needs them told apart. Everything a <div> accepts reaches the element
too.
