Elements
Resizable
A resizable group lays out panels side by side or stacked, with a handle on each boundary between two of them: a
sidebar beside a document, an editor above its console, a list beside its detail. The user drags a handle to move the
boundary, and each panel stays within the sizes the app allows it. ResizablePanelGroup owns the behavior: the drag,
which runs natively frame by frame, how the room is shared, the resize cursor and a larger touch area for a finger.
The look is the app’s: a handle is a band with no line, color or grip of its own, and what it draws is the app’s
indicator; the panels are the app’s views.
import { mount, signal, Text, View } from "nsh";
import { ResizablePanel, ResizablePanelGroup } from "nsh/base";
import { desktop, Log, Panel, Segmented, Split, ui, wide } from "./palette";
// The sizes are the app's: the group reports them when a drag ends, and the
// app passes them back as each panel's `size`.
function App() {
const sidebar = signal(desktop ? 220 : 140);
const consoleSize = signal(120);
const showConsole = signal<"Shown" | "Hidden">("Shown");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
const round = (sizes: number[]) => sizes.map((s) => Math.round(s)).join(" · ");
// The handles' look: a hairline that lights up under the pointer and while dragged.
const handle = "bg-border group-hover:bg-muted-foreground group-active:bg-primary";
const label = (title: string, detail: string) => (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: ui().text }}>{title}</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>{detail}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>resizable</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Drag a line between two panels" : "Drag a line between two panels with your finger"}
</Text>
<Split
grow
width={640}
inspector={
<>
<Panel title="sizes">
<Text style={{ fontSize: 13, fontVariant: ["tabular-nums"], color: ui().text }}>
{`sidebar ${Math.round(sidebar())} · console ${Math.round(consoleSize())}`}
</Text>
</Panel>
<Segmented label="console" options={["Shown", "Hidden"] as const} value={showConsole()} onChange={(v) => showConsole.set(v)} />
<Log title="onResize" lines={events()} minHeight={96} />
</>
}
>
<View style={{ height: wide() ? 360 : 300, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<ResizablePanelGroup
axis="horizontal"
handleClass={handle}
handleAccessibilityLabel="Resize the sidebar"
onResize={(sizes) => log(`row: ${round(sizes)}`)}
>
<ResizablePanel size={sidebar()} minSize={100} maxSize={320} onResize={(v) => sidebar.set(v)}>
<View style={{ flex: 1, padding: 14, gap: 10, backgroundColor: ui().inset.backgroundColor }}>
{label("Files", `${Math.round(sidebar())} pt, 100 to 320`)}
</View>
</ResizablePanel>
<ResizablePanel minSize={120}>
<ResizablePanelGroup
axis="vertical"
handleClass={handle}
handleAccessibilityLabel="Resize the console"
onResize={(sizes) => log(`column: ${round(sizes)}`)}
>
<ResizablePanel minSize={80}>
<View style={{ flex: 1, padding: 14 }}>{label("Editor", "Takes the room left")}</View>
</ResizablePanel>
<ResizablePanel
size={consoleSize()}
minSize={60}
fixed
visible={showConsole() === "Shown"}
onResize={(v) => consoleSize.set(v)}
>
<View style={{ flex: 1, padding: 14 }}>{label("Console", "Fixed: keeps its height")}</View>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{h_resizable, resizable_panel, theme};
use nsh_core::ui::prelude::*;
fn workspace() -> impl IntoElement {
h_resizable()
.handle_style(|s| s.bg(move || theme().border()))
.handle_accessibility_label("Resize the sidebar")
.on_resize(|sizes: &Vec<Points>| log::info!("sizes: {sizes:?}"))
.child(
resizable_panel()
.size(px(240.0))
.size_range(px(160.0)..px(480.0))
.p_4()
.child(text("Files").text_color(move || theme().muted_foreground())),
)
.child(
resizable_panel()
.p_4()
.child(text("Editor").text_color(move || theme().foreground())),
)
}import { mount, signal, Text } from "nsh";
import { ResizablePanel, ResizablePanelGroup } from "nsh/base";
function App() {
const sidebar = signal(240);
return (
<ResizablePanelGroup
axis="horizontal"
handleClass="bg-border group-hover:bg-ring group-active:bg-primary"
handleAccessibilityLabel="Resize the sidebar"
>
<ResizablePanel size={sidebar()} minSize={160} maxSize={480} onResize={(v) => sidebar.set(v)} class="p-4">
<Text class="text-muted-foreground">Files</Text>
</ResizablePanel>
<ResizablePanel class="p-4">
<Text class="text-foreground">Editor</Text>
</ResizablePanel>
</ResizablePanelGroup>
);
}
mount(App);ResizablePanelGroup and ResizablePanel come from nsh/base, the nsh-base crate in
Rust. The panels are the group’s direct children. In Rust, h_resizable() and v_resizable() make a group along each
axis (resizable_panel_group().axis(..) takes the axis as a value) and resizable_panel() a panel, with a setter per
prop, a listener per event, the typed style methods and .child(..) for their content; .size_range(min..max) sets
both limits at once.
#Sizes
A panel’s size is the size it wants, within its minSize and maxSize. The panels without one share what is left
evenly, whatever their content; when every panel has a size, the room left over is shared in proportion to them.
While the user drags a handle, the group moves the room between the panels in the core, with no JS on the way:
- Growing the panel before the handle takes room from the panels after it, nearest first, each down to its
minSize, passing overfixedones. - Shrinking it below its
minSizetakes room from the panels before it. - No panel grows beyond its
maxSize, and the panels never add up to more than the group.
When a drag ends having changed the sizes, the group reports every visible panel’s size, in the order they are laid
out (onResize on the group), and each panel whose size changed reports its own (onResize on the panel). The panels
a drag moved keep the user’s sizes until the app passes a new size; the app keeps them beyond the group’s life by
passing the reported sizes back as size, so a layout it restores from storage opens at
the sizes the user left. Only drags and screen readers’ steps report: a window resized or a panel shown or hidden
reports nothing.
When the group grows or shrinks (the window is resized), the panels with a size keep it and the panels without one
take the difference. When the group is too small for the sizes, the panels shrink, fixed ones (a sidebar, an
inspector) aside, and they come back to their sizes when the room returns. A panel with visible={false} takes no
room: the others share its space, and it comes back at its size when it shows again.
<ResizablePanelGroup axis="vertical" handleClass="bg-border">
<ResizablePanel>
<Editor />
</ResizablePanel>
<ResizablePanel size={180} minSize={120} fixed visible={consoleOpen()}>
<Console />
</ResizablePanel>
</ResizablePanelGroup>
#Handles
There is a handle on each boundary between two visible panels: a band 9 points thick across the axis, centered on the
boundary, with the resize cursor of its axis. On touch screens its touch area reaches 10 points further on either
side, so a one-point line is still easy to grab. The handle is a group: what it draws is the app’s indicator, styled
with handleClass / handleStyle: 1 point across and the boundary’s full length, so bg-border alone is a hairline on
either axis, and w-[3px] or h-[3px] thickens it, and its group-hover: and group-active: classes follow
the pointer over the band and the press or drag. For more than a line (a grip of dots, an icon), renderHandle renders
the indicator’s content, told the handle’s index, axis and state; it is drawn again when the state changes, a
few times per drag, never on every move. A grip that animates is the handle’s thumb (handleThumbClass /
handleThumbStyle): one view centered on the line, drawn upright and turned a quarter on a horizontal line so one class
serves both axes, which group-hover: and group-active: classes show and transition; the handle stays pressed for
the whole drag, wherever the pointer goes.
<ResizablePanelGroup
handleClass="w-[3px] items-center justify-center bg-border"
renderHandle={(handle) => (
<View class={handle.state === "dragging" ? "h-8 w-1 rounded-full bg-primary" : "h-6 w-1 rounded-full bg-muted-foreground"} />
)}
>
{/* panels */}
</ResizablePanelGroup>
disabled keeps every handle where it is (a locked layout).
#Props
#ResizablePanelGroup
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
axis | h_resizable(), v_resizable(), .axis(..) | "horizontal" | "vertical" | "horizontal" | Side by side (the handles are dragged left and right) or stacked (up and down). |
disabled | .disabled(..) | boolean | false | No drags: the handles stay where they are. |
handleClass, handleStyle | .handle_style(|s| ..) | string, StyleProp<ViewStyle> | none | Each handle’s indicator, on the boundary in the handle’s band: 1 point across the axis and the boundary’s full length unless it says otherwise (w-[3px], h-[3px]); group-hover: and group-active: follow the band. |
handleThumbClass, handleThumbStyle | .handle_thumb_class(..), .handle_thumb_style(|s| ..) | string, StyleProp<ViewStyle> | none | Each handle’s thumb: a grip centered on the line, drawn upright as on a vertical line and turned a quarter on a horizontal one, so one class serves both axes. It stays the same view while the handle is hovered, pressed and dragged, so group-hover: and group-active: classes transition it (h-11 w-[3px] rounded-full bg-muted-foreground opacity-0 scale-y-0 transition group-hover:opacity-40 group-hover:scale-y-[0.45] group-active:opacity-90 group-active:scale-y-100). |
renderHandle | .render_handle(|handle| ..) | (handle: ResizeHandleContext) => JSX.Element | none | What is inside each indicator, drawn again when the handle’s state changes. |
handleAccessibilityLabel | .handle_accessibility_label(..) | string | none | What screen readers say for each handle: what it resizes. They adjust it 10 points at a time. |
children | .child(..) | JSX.Element | none | The ResizablePanels, its direct children. |
class, style | style methods | string, StyleProp<ViewStyle> | size-full | The group itself; it fills its parent unless its class says otherwise. |
#ResizablePanel
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
size | .size(..) | number | none | The length it wants along the group’s axis, in points, within minSize and maxSize; a new value resizes it. Without one it shares what is left. |
minSize | .min_size(..), .size_range(..) | number | 100 | The smallest it gets. |
maxSize | .max_size(..), .size_range(..) | number | no limit | The largest it gets. |
fixed | .fixed(..) | boolean | false | Keeps its size when the group is too small for the panels; dragging its own handle still resizes it. It takes a size. |
visible | .visible(..) | boolean | true | Hidden, it takes no room and the others share its space. |
children | .child(..) | JSX.Element | none | Its content. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The panel’s own surface. Its size along the axis is the group’s. |
#ResizeHandleContext
| Field | Type | Description |
|---|---|---|
index | number | The boundary after the indexth visible panel. |
axis | "horizontal" | "vertical" | The axis it resizes along. |
state | "idle" | "hovered" | "pressed" | "dragging" | How far the pointer or finger has gone with it. |
#Events
| Event | Rust | Description |
|---|---|---|
onResize (ResizablePanelGroup) | .on_resize(|sizes: &Vec<Points>| ..) | A drag ended and moved a handle, or a screen reader adjusted one: every visible panel’s size, in the order they are laid out (left to right, top to bottom). |
onResize (ResizablePanel) | .on_resize(|size: &Points| ..) | A drag ended and changed this panel’s size: the new one, to pass back as its size. |
#Parts
| Part | Inside | Styled with | States |
|---|---|---|---|
| The group | ResizablePanelGroup’s class, style | ||
| A panel | the group | ResizablePanel’s class, style | |
| A handle’s indicator | the handle’s band, on a boundary | handleClass, handleStyle, renderHandle | group-hover: under a pointer, group-active: while pressed or dragged; state in renderHandle |
| A handle’s thumb | centered on a handle’s line | handleThumbClass, handleThumbStyle | group-hover:, group-active: (pressed until the drag ends) |
#Accessibility
- Each handle is an adjustable element for screen readers, with
handleAccessibilityLabelas its label. Its value is the size of the panel before it, within that panel’sminSizeandmaxSize. - Swiping up or down on a handle with VoiceOver, or TalkBack’s volume keys, moves it 10 points, as a drag would; the
group reports it with
onResize. Adisabledgroup’s handles stay where they are. - The panels’ content is the app’s and is read as it is anywhere else.
See Accessibility for labels, roles and announcements in general.
#Platform notes
- Desktops (macOS, Windows, Linux, GPUI) and the web: the pointer over a handle shows the column or row resize cursor, and a drag with the mouse or a touchpad moves the boundary.
- iOS, Android and Mac Catalyst: a finger drags the handle, within a touch area 10 points wider on either side than the band.
- In right-to-left layouts the panels and their sizes follow the layout’s order.
- Every platform draws what the app styles: the indicators and the panels.
- Dock’s splits are resizable groups: they are resized the same way.