Resizable
Accessible resizable panel groups and layouts with keyboard support.
import * as stylex from '@stylexjs/stylex';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { container, fontSize, fontWeight, space, stroke } from '@/lib/constants.stylex';
import { colors, font, radius } from '@/lib/tokens.stylex';
export default function ResizableDemo() {
return (
<div {...stylex.props(styles.frame)}>
<ResizablePanelGroup>
<ResizablePanel defaultSize="50%" minSize="20%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>One</span>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="50%" minSize="20%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Two</span>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
const styles = stylex.create({
// The group fills its parent, so the frame sets the size and the edge.
frame: {
borderColor: colors.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: stroke.border,
height: container.xs,
maxWidth: container.xl,
overflow: 'hidden',
width: '100%',
},
content: {
alignItems: 'center',
backgroundColor: colors.background,
color: colors.foreground,
display: 'flex',
fontFamily: font.sans,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
height: '100%',
justifyContent: 'center',
padding: space.s6,
},
// Panels get narrow as the handle moves; a one-word label truncates
// instead of spilling across the divider.
label: {
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
});
Install
npx @madeui/cli add resizable
Installs react-resizable-panels alongside the component.
Usage
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
Composition
<ResizablePanelGroup>
<ResizablePanel />
<ResizableHandle />
<ResizablePanel />
</ResizablePanelGroup>
The group fills its parent (width and height 100%), so size the wrapper. Sizes are percentages when passed as strings ("30%") and pixels when passed as numbers (200); other CSS units work as strings too ("12rem", "50vh").
Keyboard
The handle is focusable. Arrow keys move it in 5% steps along the group’s axis, Home and End jump to the neighbouring panel’s minimum and maximum, Enter collapses or expands a collapsible panel, and F6 cycles between the handles of a group.
Vertical
import * as stylex from '@stylexjs/stylex';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { container, fontSize, fontWeight, space, stroke } from '@/lib/constants.stylex';
import { colors, font, radius } from '@/lib/tokens.stylex';
export default function ResizableVertical() {
return (
<div {...stylex.props(styles.frame)}>
<ResizablePanelGroup orientation="vertical">
<ResizablePanel defaultSize="30%" minSize="20%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Header</span>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="70%" minSize="20%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Content</span>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
const styles = stylex.create({
frame: {
borderColor: colors.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: stroke.border,
height: container.card,
maxWidth: container.lg,
overflow: 'hidden',
width: '100%',
},
content: {
alignItems: 'center',
backgroundColor: colors.background,
color: colors.foreground,
display: 'flex',
fontFamily: font.sans,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
height: '100%',
justifyContent: 'center',
padding: space.s6,
},
// Panels get narrow as the handle moves; a one-word label truncates
// instead of spilling across the divider.
label: {
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
});
Handle
withHandle renders a grip on the line.
import * as stylex from '@stylexjs/stylex';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { container, fontSize, fontWeight, space, stroke } from '@/lib/constants.stylex';
import { colors, font, radius } from '@/lib/tokens.stylex';
export default function ResizableWithHandle() {
return (
<div {...stylex.props(styles.frame)}>
<ResizablePanelGroup>
<ResizablePanel defaultSize="25%" minSize="15%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Sidebar</span>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="75%" minSize="30%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Content</span>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
const styles = stylex.create({
frame: {
borderColor: colors.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: stroke.border,
height: container.xs,
maxWidth: container.xl,
overflow: 'hidden',
width: '100%',
},
content: {
alignItems: 'center',
backgroundColor: colors.background,
color: colors.foreground,
display: 'flex',
fontFamily: font.sans,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
height: '100%',
justifyContent: 'center',
padding: space.s6,
},
// Panels get narrow as the handle moves; a one-word label truncates
// instead of spilling across the divider.
label: {
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
});
Nested
import * as stylex from '@stylexjs/stylex';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { container, fontSize, fontWeight, space, stroke } from '@/lib/constants.stylex';
import { colors, font, radius } from '@/lib/tokens.stylex';
export default function ResizableNested() {
return (
<div {...stylex.props(styles.frame)}>
<ResizablePanelGroup>
<ResizablePanel defaultSize="50%" minSize="20%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>One</span>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50%" minSize="20%">
<ResizablePanelGroup orientation="vertical">
<ResizablePanel defaultSize="25%" minSize="20%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Two</span>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="75%" minSize="20%">
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Three</span>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
const styles = stylex.create({
frame: {
borderColor: colors.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: stroke.border,
height: container.card,
maxWidth: container.xl,
overflow: 'hidden',
width: '100%',
},
content: {
alignItems: 'center',
backgroundColor: colors.background,
color: colors.foreground,
display: 'flex',
fontFamily: font.sans,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
height: '100%',
justifyContent: 'center',
padding: space.s6,
},
// Panels get narrow as the handle moves; a one-word label truncates
// instead of spilling across the divider.
label: {
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
});
Collapsible
A collapsible panel snaps to collapsedSize when dragged below its minSize. Double-clicking a handle restores the default sizes.
import * as stylex from '@stylexjs/stylex';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { container, fontSize, fontWeight, space, stroke } from '@/lib/constants.stylex';
import { colors, font, radius } from '@/lib/tokens.stylex';
export default function ResizableCollapsible() {
return (
<div {...stylex.props(styles.frame)}>
<ResizablePanelGroup>
<ResizablePanel
collapsible
collapsedSize="8%"
defaultSize="30%"
minSize="20%"
>
<div {...stylex.props(styles.content)}>
<span {...stylex.props(styles.label)}>Sidebar</span>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="70%" minSize="40%">
<div {...stylex.props(styles.content)}>Drag the handle left to collapse</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
const styles = stylex.create({
frame: {
borderColor: colors.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: stroke.border,
height: container.xs,
maxWidth: container.xl,
overflow: 'hidden',
width: '100%',
},
content: {
alignItems: 'center',
backgroundColor: colors.background,
color: colors.foreground,
display: 'flex',
fontFamily: font.sans,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
height: '100%',
justifyContent: 'center',
padding: space.s6,
textAlign: 'center',
},
// Panels get narrow as the handle moves; a one-word label truncates
// instead of spilling across the divider.
label: {
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
});
Persisted layout
useDefaultLayout saves the layout under an id and restores it on the next visit. Give each panel a stable id.
'use client';
import * as React from 'react';
import * as stylex from '@stylexjs/stylex';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@/components/ui/resizable';
import { container, fontSize, fontWeight, space, stroke } from '@/lib/constants.stylex';
import { colors, font, radius } from '@/lib/tokens.stylex';
// The saved layout lives in localStorage, which only exists in the browser.
// Mount the group after hydration so the server markup and the first client
// render match; the frame keeps its size meanwhile.
const subscribe = () => () => {};
const useMounted = () =>
React.useSyncExternalStore(
subscribe,
() => true,
() => false
);
export default function ResizablePersisted() {
const mounted = useMounted();
return (
<div {...stylex.props(styles.frame)}>{mounted ? <PersistedGroup /> : null}</div>
);
}
function PersistedGroup() {
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'resizable-persisted-example',
});
return (
<ResizablePanelGroup
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="left" defaultSize="50%" minSize="20%">
<div {...stylex.props(styles.content)}>Resize me</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="right" defaultSize="50%" minSize="20%">
<div {...stylex.props(styles.content)}>Then reload the page</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
const styles = stylex.create({
frame: {
borderColor: colors.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: stroke.border,
height: container.xs,
maxWidth: container.xl,
overflow: 'hidden',
width: '100%',
},
content: {
alignItems: 'center',
backgroundColor: colors.background,
color: colors.foreground,
display: 'flex',
fontFamily: font.sans,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
height: '100%',
justifyContent: 'center',
padding: space.s6,
textAlign: 'center',
},
});
API reference
Built on react-resizable-panels v4: ResizablePanelGroup wraps Group, ResizablePanel wraps Panel, and ResizableHandle wraps Separator. The tables below cover the props this library adds or changes — every other prop (defaultSize, minSize, maxSize, collapsible, onLayoutChanged, panelRef, …) is forwarded to the underlying part; see the react-resizable-panels docs for the full list. useDefaultLayout is re-exported unchanged.
ResizablePanelGroup
| Prop | Type | Default | Description |
|---|---|---|---|
orientation |
'horizontal' | 'vertical' |
'horizontal' |
|
resizeTargetMinimumSize |
{ coarse: number; fine: number } |
{ coarse: 24, fine: 10 } |
Size in CSS pixels of the pointer target around each handle. The handle draws a 1px line; this is what the pointer actually has to hit. coarse is 24 so a touch drag meets the WCAG 2.2 minimum target size. |
style |
StyleXStyles |
— | StyleX styles merged last — always win over the component’s own styles. The library sets width, height, display, flex-direction, and overflow inline. |
ResizableHandle
| Prop | Type | Default | Description |
|---|---|---|---|
withHandle |
boolean |
false |
Render a centered grip pill on the line. |
aria-label |
string |
'Resize panels' |
Accessible name for the handle, which is focusable and announced as a splitter. Override it when a group has several handles that need telling apart. |
style |
StyleXStyles |
— | StyleX styles merged last — always win over the component’s own styles. |
Styling
ResizablePanelGroup, ResizablePanel, ResizableHandle accept style (StyleXStyles, merged last so caller overrides always win). ResizablePanel styles land on the panel’s content box, which the library gives overflow: auto.