Install
Two entry points: motion-panels is the resizing engine and depends on nothing but motion, motion-panels/react is the components. React is an optional peer, so the core alone never loads the React types.
pnpm add motion-panels motion # the core alone
pnpm add motion-panels motion react # with the React adapterimport { createPanel, const createPanelGroup: (orientation?: Orientation) => PanelGroupcreatePanelGroup } from 'motion-panels'import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'shadcn
The registry ships one item: a styled separator over the same components, written into your project as components/ui/motion-panels.tsx and yours to edit. It pulls the package in for you.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
npx shadcn@latest add https://motion-panels.letstri.dev/r/motion-panels.jsonimport { PanelGroup, Panel, PanelSeparator } from '@/components/ui/motion-panels'
import { useState } from 'react'
export function Layout() {
const [width, setWidth] = useState(240)
return (
<PanelGroup orientation="horizontal">
<Panel size={width} minSize={160} maxSize={420} onSizeChange={setWidth}>
<FileTree />
</Panel>
<PanelSeparator withHandle />
<Panel>
<Editor />
</Panel>
</PanelGroup>
)
}Quick start
A group is a flex container. A panel with a size holds it, a panel without one fills what is left. That is the whole layout. A size is pixels, or a percentage of the group that follows it as it resizes; onSizeChange reports whichever form it was given. No separator here — a sized panel is draggable by the edge facing the filling panel, so grab the seam below and pull. Hover any identifier in a snippet to read its real type.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.useState } from 'react'
export function function Layout(): JSX.ElementLayout() {
const [const width: numberwidth, const setWidth: Dispatch<SetStateAction<number>>setWidth] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(240)
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup orientation?: "horizontal" | "vertical" | undefinedorientation="horizontal">
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel
size: numbersize={const width: numberwidth}
minSize?: Size | undefinedminSize={160}
maxSize?: Size | undefinedmaxSize={420}
onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setWidth: Dispatch<SetStateAction<number>>setWidth}
>
<function FileTree(): ReactNodeFileTree />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<function Editor(): ReactNodeEditor />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}Separator
Drop a Separator between two panels and the same split gains a visible grip, keyboard control and double-click reset. It finds the sized panel next to it, resizes that one, and sits centred on the seam without taking space in the flow. It is a focusable [role='separator'] carrying the panel size on aria-valuenow.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
| Key | Does |
|---|---|
Arrows | Grow or shrink by 10px, along the group axis |
Shift + arrows | The same, by 50px |
Page up / Page down | The same, by 50px, without a modifier |
Home / End | Jump to minSize or maxSize |
Enter | Toggle collapsed (needs onCollapsedChange) |
Double-click | Reset to defaultSize, or to the size the panel mounted with |
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
export function function Layout(): JSX.ElementLayout() {
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel size: 240size={240} minSize?: Size | undefinedminSize={160} maxSize?: Size | undefinedmaxSize={420}>
<function FileTree(): ReactNodeFileTree />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<function Editor(): ReactNodeEditor />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}Orientation
The same split on the other axis. Nothing about the panel or the separator changes — the group decides the axis, the cursor, the separator orientation and which arrow keys grow it.
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
- $ pnpm add motion-panels
- Packages: +1
- done in 1.2s
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.useState } from 'react'
export function function Workspace(): JSX.ElementWorkspace() {
const [const height: numberheight, const setHeight: Dispatch<SetStateAction<number>>setHeight] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(120)
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup orientation?: "vertical" | "horizontal" | undefinedorientation="vertical">
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<function Editor(): ReactNodeEditor />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel size: numbersize={const height: numberheight} minSize?: Size | undefinedminSize={80} maxSize?: Size | undefinedmaxSize={220} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setHeight: Dispatch<SetStateAction<number>>setHeight}>
<function Output(): ReactNodeOutput />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}Collapsing and folds
A collapsed panel folds to zero, and its content animates with whatever motion props the panel carries, so the fold is yours to design. Pick a preset and toggle. The content is anchored to the edge facing the filling panel, so a slide leans into the fold and originX pins a scale to that same edge. Passing onCollapsedChange also turns on drag-below-half-the-minimum and Enter on the separator.
- Group
- Panel
- Separator
- createPanel
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
<Panel
animate={{ rotateY: 0, transformPerspective: 500 }}
initial={{ rotateY: -75, transformPerspective: 500 }}
style={{ originX: 1 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
/>import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.useState } from 'react'
export function function Workspace(): JSX.ElementWorkspace() {
const [const width: numberwidth, const setWidth: Dispatch<SetStateAction<number>>setWidth] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(260)
const [const collapsed: booleancollapsed, const setCollapsed: Dispatch<SetStateAction<boolean>>setCollapsed] = useState<boolean>(initialState: boolean | (() => boolean)): [boolean, Dispatch<SetStateAction<boolean>>] (+1 overload)Returns a stateful value, and a function to update it.useState(false)
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel
size: numbersize={const width: numberwidth}
minSize?: Size | undefinedminSize={180}
collapsed?: boolean | undefinedcollapsed={const collapsed: booleancollapsed}
onCollapsedChange?: ((collapsed: boolean) => void) | undefinedonCollapsedChange={const setCollapsed: Dispatch<SetStateAction<boolean>>setCollapsed}
onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setWidth: Dispatch<SetStateAction<number>>setWidth}
initial?: boolean | TargetAndTransition | VariantLabels | undefinedProperties, variant label or array of variant labels to start in.
Set to `false` to initialise with the values in `animate` (disabling the mount animation)
```jsx
// As values
<motion.div initial={{ opacity: 1 }} />
// As variant
<motion.div initial="visible" variants={variants} />
// Multiple variants
<motion.div initial={["visible", "active"]} variants={variants} />
// As false (disable mount animation)
<motion.div initial={false} animate={{ opacity: 0 }} />
```initial={{ scale?: ValueKeyframesDefinition | undefined[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/scale)scale: 0.9 }}
animate?: boolean | TargetAndTransition | VariantLabels | LegacyAnimationControls | undefinedValues to animate to, variant label(s), or `LegacyAnimationControls`.
```jsx
// As values
<motion.div animate={{ opacity: 1 }} />
// As variant
<motion.div animate="visible" variants={variants} />
// Multiple variants
<motion.div animate={["visible", "active"]} variants={variants} />
// LegacyAnimationControls
<motion.div animate={animation} />
```animate={{ scale?: ValueKeyframesDefinition | undefined[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/scale)scale: 1 }}
transition?: (Transition<any> & Transition) | undefinedDefault transition. If no `transition` is defined in `animate`, it will use the transition defined here.
```jsx
const spring = {
type: "spring",
damping: 10,
stiffness: 100
}
<motion.div transition={spring} animate={{ scale: 1.2 }} />
```transition={{ bounce?: number | undefined`bounce` determines the "bounciness" of a spring animation.
`0` is no bounce, and `1` is extremely bouncy.
If `duration` is set, this defaults to `0.25`.
Note: `bounce` and `duration` will be overridden if `stiffness`, `damping` or `mass` are set.bounce: 0.4, ValueTransition.duration?: number | undefinedThe duration of the tween animation. Set to `0.3` by default, 0r `0.8` if animating a series of keyframes.duration: 0.7, ValueTransition.type?: AnimationGeneratorType | undefinedType of animation to use.
- "tween": Duration-based animation with ease curve
- "spring": Physics or duration-based spring animation
- false: Use an instant animationtype: 'spring' }}
style?: MotionStyle | undefined
The React DOM `style` prop, enhanced with support for `MotionValue`s and separate `transform` values.
```jsx
export const MyComponent = () => {
const x = useMotionValue(0)
return <motion.div style={{ x, opacity: 1, scale: 0.5 }} />
}
```style={{ originX?: MotionValueHelper<AnyResolvedKeyframe | undefined>originX: 1 }}
>
<function Navigator(): ReactNodeNavigator />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<function Editor(): ReactNodeEditor />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}Pinning
A filling panel reflows its content on every frame of a fold. A pinned one sizes the content once, up front, and anchors it to the edge that is not moving, so the content holds still while the fold slides the panel edge across it. Toggle the pin off and watch the paragraph rewrap the whole way through.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
Pinning holds this text at the width the panel ends the fold with, so the line breaks are measured once instead of on every frame. Turn the pin off and watch the words rewrap the whole way through. Real content pays that cost on every frame too: a code editor relaying out, a virtualised table remeasuring its rows.
Pin content that bleeds to its own edges: an editor, a document, a table. A block with its own border or rounded corners shows that edge jumping instead, which is why the paragraph here has no frame of its own. The anchor follows the fold, so a sized panel placed after the filling one pins to the start edge instead.
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.useState } from 'react'
export function function Workspace(): JSX.ElementWorkspace() {
const [const width: numberwidth, const setWidth: Dispatch<SetStateAction<number>>setWidth] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(240)
const [const collapsed: booleancollapsed, const setCollapsed: Dispatch<SetStateAction<boolean>>setCollapsed] = useState<boolean>(initialState: boolean | (() => boolean)): [boolean, Dispatch<SetStateAction<boolean>>] (+1 overload)Returns a stateful value, and a function to update it.useState(false)
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel
size: numbersize={const width: numberwidth}
minSize?: Size | undefinedminSize={160}
collapsed?: boolean | undefinedcollapsed={const collapsed: booleancollapsed}
onCollapsedChange?: ((collapsed: boolean) => void) | undefinedonCollapsedChange={const setCollapsed: Dispatch<SetStateAction<boolean>>setCollapsed}
onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setWidth: Dispatch<SetStateAction<number>>setWidth}
>
<function FileTree(): ReactNodeFileTree />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel pin?: boolean | undefinedpin>
<function Document(): ReactNodeDocument />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}Nesting and intersections
Groups nest: here a vertical split lives inside the filling panel of a horizontal one. Where the two seams meet, press near the crossing and both separators follow the pointer — the cursor turns to move and each one resizes its own panel. Nothing to add: any separator whose grip reaches the pointer joins the drag.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
- $ pnpm add motion-panels
- Packages: +1
- done in 1.2s
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.useState } from 'react'
export function function Ide(): JSX.ElementIde() {
const [const sidebar: numbersidebar, const setSidebar: Dispatch<SetStateAction<number>>setSidebar] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(200)
const [const terminal: numberterminal, const setTerminal: Dispatch<SetStateAction<number>>setTerminal] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(100)
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel size: numbersize={const sidebar: numbersidebar} minSize?: Size | undefinedminSize={140} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setSidebar: Dispatch<SetStateAction<number>>setSidebar}>
<function FileTree(): ReactNodeFileTree />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup orientation?: "vertical" | "horizontal" | undefinedorientation="vertical">
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<function Editor(): ReactNodeEditor />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel size: numbersize={const terminal: numberterminal} minSize?: Size | undefinedminSize={60} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setTerminal: Dispatch<SetStateAction<number>>setTerminal}>
<function Console(): ReactNodeConsole />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}Depth is not limited. Below, a horizontal split sits in the top panel of a vertical split, which sits in the filling panel of the outer row. Both crossings resize both axes: files with terminal at the left end of the terminal seam, outline with terminal at its right end — two levels apart, and neither knows about the other.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
- Group
- Panel
- Separator
- createPanel
- $ pnpm add motion-panels
- Packages: +1
- done in 1.2s
Reordering
Grab the dots in a panel header and carry the panel across: the two sides trade places and travel there. Give the group the order it should read and a callback, give each movable panel its value, and the drag is motion's own reorder gesture — the order lives in your state, so it is yours to persist.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
- Group
- Panel
- Separator
- createPanel
order: [files, outline]
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Handle: ({ "aria-label": ariaLabel, onKeyDown, onPointerDown, style, type, ...props }: HandleProps) => JSX.Element | nullHandle, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.useState } from 'react'
const const SIDES: Record<string, ReactNode>SIDES: type Record<K extends keyof any, T> = { [P in K]: T; }Construct a type with a set of properties K of type TRecord<string, type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefinedRepresents all of the things React can render.
Where
{@link
ReactElement
}
only represents JSX, `ReactNode` represents everything that can be rendered.ReactNode> = {
files: JSX.Elementfiles: <function Files(): ReactNodeFiles />,
outline: JSX.Elementoutline: <function Outline(): ReactNodeOutline />,
}
export function function Workspace(): JSX.ElementWorkspace() {
const [const order: string[]order, const setOrder: Dispatch<SetStateAction<string[]>>setOrder] = useState<string[]>(initialState: string[] | (() => string[])): [string[], Dispatch<SetStateAction<string[]>>] (+1 overload)Returns a stateful value, and a function to update it.useState(['files', 'outline'])
const const side: (id: string) => JSX.Elementside = (id: stringid: string) => (
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel Attributes.key?: Key | null | undefinedkey={id: stringid} size: 200size={200} value?: unknownvalue={id: stringid}>
<const Handle: ({ "aria-label": ariaLabel, onKeyDown, onPointerDown, style, type, ...props }: HandleProps) => JSX.Element | nullHandle className?: string | undefinedclassName={const GRIP: stringGRIP}>⠿</const Handle: ({ "aria-label": ariaLabel, onKeyDown, onPointerDown, style, type, ...props }: HandleProps) => JSX.Element | nullHandle>
{const SIDES: Record<string, ReactNode>SIDES[id: stringid]}
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
)
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup order?: string[] | undefinedorder={const order: string[]order} onOrderChange?: ((order: string[]) => void) | undefinedonOrderChange={const setOrder: Dispatch<SetStateAction<string[]>>setOrder}>
{const side: (id: string) => JSX.Elementside(const order: string[]order[0])}
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<function Editor(): ReactNodeEditor />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator />
{const side: (id: string) => JSX.Elementside(const order: string[]order[1])}
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}The filling panel carries no value here, and that is the whole rule: a group holds at most one sized panel on each side of it, so the panels that move are the ones you name in order, and no drag can reach an order the group cannot hold. A panel left out stays where it is — the middle card above shows no dots, because Handle renders nothing inside a panel the group does not move. Handles are buttons: focus one and the arrow keys along the group axis move the panel too. Motion takes the drag from there — it snaps the panel back if it lands nowhere and scrolls a long group at the edges — and a right-to-left group carries the panel the way the row reads.
Panels on both edges
Each sized panel finds its own side: one before the filling panel drags on its end edge, one after it on its start edge. Two sized panels around one filling panel need no extra wiring, and again no separators. These two are percentages and stay percentages: a drag reports one back, so they keep following the group.
- index.tsx
- group.tsx
- panel.tsx
- separator.tsx
- export function Workspace() {
- const [width, setWidth] = useState(240)
- return (
- <Group orientation="horizontal">
- <Panel size={width} onSizeChange={setWidth} />
- <Separator />
- <Panel pin />
- </Group>
- )
- }
- Group
- Panel
- Separator
- createPanel
import type { type Size = number | `${number}%`Size } from 'motion-panels/react'
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.useState } from 'react'
export function function Workbench(): JSX.ElementWorkbench() {
const [const left: Sizeleft, const setLeft: Dispatch<SetStateAction<Size>>setLeft] = useState<Size>(initialState: Size | (() => Size)): [Size, Dispatch<SetStateAction<Size>>] (+1 overload)Returns a stateful value, and a function to update it.useState<type Size = number | `${number}%`Size>('25%')
const [const right: Sizeright, const setRight: Dispatch<SetStateAction<Size>>setRight] = useState<Size>(initialState: Size | (() => Size)): [Size, Dispatch<SetStateAction<Size>>] (+1 overload)Returns a stateful value, and a function to update it.useState<type Size = number | `${number}%`Size>('25%')
return (
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel size: Sizesize={const left: Sizeleft} minSize?: Size | undefinedminSize="14%" maxSize?: Size | undefinedmaxSize="35%" onSizeChange?: ((size: Size) => void) | undefinedonSizeChange={const setLeft: Dispatch<SetStateAction<Size>>setLeft}>
<function FileTree(): ReactNodeFileTree />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<function Editor(): ReactNodeEditor />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
<const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel size: Sizesize={const right: Sizeright} minSize?: Size | undefinedminSize="14%" maxSize?: Size | undefinedmaxSize="35%" onSizeChange?: ((size: Size) => void) | undefinedonSizeChange={const setRight: Dispatch<SetStateAction<Size>>setRight}>
<function Outline(): ReactNodeOutline />
</const Panel: {
<S extends Size>(props: SizedPanelProps<S>): ReactElement;
(props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a
wrapper reads the last one, and a lone generic would resolve there to its
constraint, handing the wrapper's consumers `Size` instead of the form they
passed. The last signature is the shapes side by side, which a call site
narrows on its own size.Panel>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup>
)
}Core, without React
The demo below renders no components: it builds a group and a panel from the core and wires them to plain DOM nodes. Same bounds, same folds, same keyboard — drag the seam, or focus the grip and use the arrows. This is the whole surface an adapter for another framework has to cover.
import { const attachSeparator: (element: HTMLElement, group: PanelGroup, own?: PanelController) => () => voidattachSeparator, const createPanel: <S extends Size>(group: PanelGroup, initial: PanelOptions<S>) => PanelController<S>createPanel, const createPanelGroup: (orientation?: Orientation) => PanelGroupcreatePanelGroup, const FILL_ATTRIBUTE: "data-motion-panels-fill"FILL_ATTRIBUTE } from 'motion-panels'
export function function mountSplit(root: HTMLElement, panel: HTMLElement, fill: HTMLElement, grip: HTMLElement): () => voidmountSplit(root: HTMLElementroot: HTMLElement, panel: HTMLElementpanel: HTMLElement, fill: HTMLElementfill: HTMLElement, grip: HTMLElementgrip: HTMLElement) {
const const group: PanelGroupgroup = function createPanelGroup(orientation?: Orientation): PanelGroupcreatePanelGroup('horizontal')
var Object: ObjectConstructorProvides functionality common to all JavaScript objects.Object.ObjectConstructor.assign<CSSStyleDeclaration, {
display: string;
flexDirection: "row" | "column";
overflow: string;
}>(target: CSSStyleDeclaration, source: {
display: string;
flexDirection: "row" | "column";
overflow: string;
}): CSSStyleDeclaration & {
display: string;
flexDirection: "row" | "column";
overflow: string;
} (+3 overloads)
Copy the values of all of the enumerable own properties from one or more source objects to a
target object. Returns the target object.assign(root: HTMLElementroot.ElementCSSInlineStyle.style: CSSStyleDeclaration[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)style, { display: stringdisplay: 'flex', flexDirection: "row" | "column"flexDirection: const group: PanelGroupgroup.PanelGroup.axes: Axesaxes.direction: "row" | "column"direction, overflow: stringoverflow: 'clip' })
fill: HTMLElementfill.Element.setAttribute(qualifiedName: string, value: string): voidThe **`setAttribute()`** method of the Element interface sets the value of an attribute on the specified element.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Element/setAttribute)setAttribute(const FILL_ATTRIBUTE: "data-motion-panels-fill"FILL_ATTRIBUTE, '')
let let size: numbersize = 240
const const base: {
minSize: number;
maxSize: number;
onSizeChange: (next: number) => void;
}
base = {
minSize: numberminSize: 160,
maxSize: numbermaxSize: 420,
onSizeChange: (next: number) => voidonSizeChange: (next: numbernext: number) => {
let size: numbersize = next: numbernext
const controller: PanelController<number>controller.PanelController<number>.sync(options: PanelOptions<number>, mounting?: boolean): voidsync({ ...const base: {
minSize: number;
maxSize: number;
onSizeChange: (next: number) => void;
}
base, PanelOptions<number>.size: numbersize })
},
}
const const controller: PanelController<number>controller = createPanel<number>(group: PanelGroup, initial: PanelOptions<number>): PanelController<number>createPanel(const group: PanelGroupgroup, { ...const base: {
minSize: number;
maxSize: number;
onSizeChange: (next: number) => void;
}
base, PanelOptions<number>.size: numbersize })
const const detach: () => voiddetach = const controller: PanelController<number>controller.PanelController<number>.attach: (element: HTMLElement) => () => voidattach(panel: HTMLElementpanel)
const controller: PanelController<number>controller.PanelController<number>.motion: {
content: MotionValue<number>;
size: MotionValue<number>;
}
motion.size: MotionValue<number>size.MotionValue<number>.on<"change">(eventName: "change", callback: (latestValue: number) => void): VoidFunctionon('change', (value: numbervalue) => {
panel: HTMLElementpanel.ElementCSSInlineStyle.style: CSSStyleDeclaration[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)style.CSSStyleDeclaration.width: string[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/width)width = `${var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.Math.Math.max(...values: number[]): numberReturns the larger of a set of supplied numeric expressions.max(0, value: numbervalue)}px`
})
grip: HTMLElementgrip.ARIAMixin.role: string | null[MDN Reference](https://developer.mozilla.org/docs/Web/API/Element/role)role = 'separator'
grip: HTMLElementgrip.HTMLOrSVGElement.tabIndex: number[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/tabIndex)tabIndex = 0
grip: HTMLElementgrip.ARIAMixin.ariaOrientation: string | null[MDN Reference](https://developer.mozilla.org/docs/Web/API/Element/ariaOrientation)ariaOrientation = const group: PanelGroupgroup.PanelGroup.axes: Axesaxes.separator: "horizontal" | "vertical"separator
const const detachGrip: () => voiddetachGrip = function attachSeparator(element: HTMLElement, group: PanelGroup, own?: PanelController): () => voidattachSeparator(grip: HTMLElementgrip, const group: PanelGroupgroup, const controller: PanelController<number>controller)
return () => {
const detachGrip: () => voiddetachGrip()
const detach: () => voiddetach()
const controller: PanelController<number>controller.PanelController<number>.destroy: () => voiddestroy()
}
}attach reads the panel's place in the group — which side of the filling panel it sits on, and so which edge drags — and returns the detach. attachSeparator wires the separator: pointer drags and crossings, keyboard, double-click reset, and the live aria-value and data attributes. sync feeds the panel new options on every state change, the same calls the React adapter makes in layout effects. Everything else is state you already own.
What every element needs
The core owns numbers, never nodes. It reads one attribute and hands back motion values and a state object; laying the flexbox out is the adapter's half of the deal. This is that half, in full.
| Element | What you give it |
|---|---|
group root | display: flex, flex-direction from axes.direction, and overflow: clip on the outermost group. |
filling panel | The FILL_ATTRIBUTE plus flex: 1 and a zero min-width or min-height. Every sized panel finds its own side by looking for this one. |
sized panel | flex-shrink: 0 and its extent from motion.size, floored at 0. |
panel content | flex-shrink: 0, 100% on the cross axis, and its extent from motion.content — the value that holds a layout still while the panel edge slides across it. |
separator | role='separator', tabIndex, aria-orientation from axes.separator, touch-action: none, and attachSeparator. It writes aria-valuenow, data-resizing and data-crossing itself. |
pinned fill | A flex wrapper with justify-content from group.fill.anchor, and the child sized by group.fill.size. Both are motion values the folding panel drives. |
Folds and reorders
The demo above stops at a drag. A fold is one sync away, and a reorder is two calls around the DOM change: measure the children before, play the trip after.
import type { interface PanelController<S extends Size = Size>PanelController, PanelGroup, interface PanelOptions<S extends Size = Size>PanelOptions } from 'motion-panels'
import { const reorder: {
measure: (root: HTMLElement, axes: Axes) => Map<Element, number>;
play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => void;
}
reorder } from 'motion-panels'
export function function wireExtras(controller: PanelController, options: PanelOptions, content: HTMLElement, toggle: HTMLElement): voidwireExtras(controller: PanelController<Size>controller: interface PanelController<S extends Size = Size>PanelController, options: PanelOptions<Size>options: interface PanelOptions<S extends Size = Size>PanelOptions, content: HTMLElementcontent: HTMLElement, toggle: HTMLElementtoggle: HTMLElement) {
let let collapsed: booleancollapsed = false
toggle: HTMLElementtoggle.HTMLElement.addEventListener<"click">(type: "click", listener: (this: HTMLElement, ev: PointerEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)
The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)addEventListener('click', () => {
let collapsed: booleancollapsed = !let collapsed: booleancollapsed
controller: PanelController<Size>controller.PanelController<Size>.sync(options: PanelOptions<Size>, mounting?: boolean): voidsync({ ...options: PanelOptions<Size>options, PanelOptions<Size>.collapsed?: boolean | undefinedcollapsed })
})
controller: PanelController<Size>controller.PanelController<Size>.motion: {
content: MotionValue<number>;
size: MotionValue<number>;
}
motion.content: MotionValue<number>content.MotionValue<number>.on<"change">(eventName: "change", callback: (latestValue: number) => void): VoidFunctionon('change', (value: numbervalue) => {
content: HTMLElementcontent.ElementCSSInlineStyle.style: CSSStyleDeclaration[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)style.CSSStyleDeclaration.width: string[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/width)width = `${value: numbervalue}px`
})
}
export function function move(root: HTMLElement, axes: PanelGroup["axes"], change: () => void): voidmove(root: HTMLElementroot: HTMLElement, axes: Axesaxes: PanelGroup['axes'], change: () => voidchange: () => void) {
const const before: Map<Element, number>before = const reorder: {
measure: (root: HTMLElement, axes: Axes) => Map<Element, number>;
play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => void;
}
reorder.measure: (root: HTMLElement, axes: Axes) => Map<Element, number>measure(root: HTMLElementroot, axes: Axesaxes)
change: () => voidchange()
const reorder: {
measure: (root: HTMLElement, axes: Axes) => Map<Element, number>;
play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => void;
}
reorder.play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => voidplay(const before: Map<Element, number>before, axes: Axesaxes)
}Styling
Nothing ships styled. A separator is [role='separator'] with aria-orientation, centred on the seam it drags and taking no space in the flow, so give it a width and it straddles the boundary on its own. It carries data-crossing while the pointer hovers a crossing it would drag from, and data-resizing from press to release. A sized panel with no Separator of its own renders one anyway as the drag area on its edge — that one carries data-motion-panels-edge and should stay invisible until it is focused: it is still a tab stop, so give it a focus-visible outline.
[role='separator'][aria-orientation='vertical'] {
width: 14px;
}
[role='separator']::after {
border-radius: 999px;
background: var(--border);
content: '';
}
[role='separator']:hover::after,
[role='separator'][data-crossing]::after {
background: var(--muted-foreground);
}
[role='separator'][data-resizing]::after {
background: var(--primary);
}
[role='separator'][data-motion-panels-edge]::after {
display: none;
}
[role='separator'][data-motion-panels-edge]:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}API
Every component forwards the rest of its props to a motion div.
Group
| Prop | Type | Does |
|---|---|---|
orientation | 'horizontal' | 'vertical' | Axis the panels split on. Groups nest. |
transition | Transition | Timing of the reorder trip: keyed children rendered in a new order travel there. Defaults to the house curve. |
reorder | boolean (default true) | Pass false and reordered children jump to their new place instead of travelling, with nothing measured on the way. |
order | V[] | The order the movable panels read in, one entry per panel that may move. A panel left out of it stays where it is, which is how the filling panel keeps its place. |
onOrderChange | (order: V[]) => void | The new order, after a drag or an arrow key carried a panel past its neighbour. Hand it straight to your setState: render the panels in that order and they travel there. With an order set, the group hands the trip to motion and leaves its own alone. |
Panel
| Prop | Type | Does |
|---|---|---|
size | number | string | Current size, in pixels or as a percentage of the group extent. A percentage follows the group as it resizes. Omit it and the panel fills what is left. |
onFoldEnd | () => void | The panel finished travelling to its size — a fold, an unfold, or the settle after a drag. Sequence work on it instead of guessing at a duration: wait for the fold before reordering, and nothing moves while a panel is still closing. |
onSizeChange | (size: number | string) => void | Called with the new size as a drag or key press lands, in the form size was given: a number reports pixels, a percent string reports a percentage. A wrapper component reading ComponentProps sees the pixel form; one that forwards percentages takes PanelProps<Size>. |
defaultSize | number | string | Size a double-click on the separator resets to. Defaults to the size the panel mounted with. |
minSize / maxSize | number | string | Drag and keyboard bounds, in pixels or as a percentage of the group extent. Both clamp to the room the other panels leave, and max defaults to all of it. |
overshoot | boolean | number (default 22) | A drag that reaches minSize or maxSize keeps stretching a little past it, then springs back on release, so the edge shows it has run out rather than looking stuck. A number sets how far that stretch reaches, in pixels; false or 0 stops the drag dead at the bound. |
collapsed | boolean | Folds the panel to zero. Dragging below half of minSize sets it too, once onCollapsedChange is there to hear it. |
onCollapsedChange | (collapsed: boolean) => void | Required for drag-to-collapse and Enter-to-toggle. |
keepMounted | boolean (default true) | Keeps the content mounted once the panel has been open, clipped at zero while collapsed, so reopening costs no mount: the fold animates it between initial (or exit) and animate. A panel that has never been open mounts nothing, and one that mounts open skips its entrance. Pass false to unmount on every close instead. |
transition | Transition | Timing of the fold. Defaults to the house curve. |
initial / animate / exit | motion props | Applied to the content while the panel folds. |
value | unknown | This panel entry in the group order. Given one, the panel becomes a motion reorder item that a Handle inside it can carry. Without one it never moves. |
pin | boolean | Filling panels only. Lays the content out once per fold instead of once per frame. |
Handle
The grip that moves a panel. Renders a motion button, so anything inside it is yours, and aria-label defaults to the panel value — 'Move files' for a panel valued files — or to 'Move panel' where the value is not a string or a number. Put one anywhere inside a panel that carries a value: pressing it starts motion's reorder drag, and the arrow keys along the group axis move the panel a place at a time. Inside a panel with no value, or a group with no order, it renders nothing at all. While a panel is carried, its group takes no pointer events, so nothing lights up under it.
Separator
No props of its own beyond transition; the rest reaches a motion div, and aria-label defaults to 'Resize panel'. Optional: rendered between two panels it resizes the sized one and sits over its edge without taking flow space, and a flex gap on the group opens on both sides of it, so the seam is twice the gap with the grip in its middle. It keeps aria-valuenow, aria-valuetext and aria-valuemin current, adds aria-valuemax once the panel has a maxSize, and marks itself with data-resizing and data-crossing.
motion-panels
The core, for an adapter or for plain DOM. Nothing here imports React.
| Prop | Type | Does |
|---|---|---|
createPanelGroup | (orientation?) => PanelGroup | The shared registry: axes, the filling panel motion values, the sized panels by side. |
createPanel | (group, options) => PanelController | One panel state machine: bounds, drag, keyboard, folds, collapse. |
controller.attach | (element) => () => void | Reads the panel place in the group and registers it. Returns the detach. |
controller.sync | (options) => void | Feeds new options in. Changing the target starts a fold. |
controller.motion | { content, size } | MotionValues for the panel and its content. Bind size to width or height. |
controller.bounds | () => { min, max } | The bounds in pixels, percentages resolved and clamped to the room the other panels leave. |
controller.target | number | The size the panel is settling on, 0 while collapsed. What a separator reports as aria-valuenow. |
controller.reset | () => void | Calls onSizeChange with defaultSize, or the size the panel mounted with. The double-click. |
controller.state | { bare, dragging, end, folding } | Read it through subscribe. A frozen object, replaced only when it changes. |
controller.subscribe | (listener) => () => void | Fires when the state or the target changes. This is the store useSyncExternalStore reads. |
controller.destroy | () => void | Drops the listeners and any body lock the panel still holds. Call it after the detach. |
controller.drag | { start, move, end, cancel } | Pointer drag, in the units your gesture layer reports. |
controller.resizeByKey | (event) => void | Arrows, Shift, PageUp / PageDown, Home / End, Enter. Takes any KeyboardEvent. |
attachSeparator | (element, group, own?) => () => void | Wires a separator element: pointer drags and crossings, keyboard, double-click reset, live aria-value and data attributes. Without own it resizes the panel its slot sits beside. Returns the detach. |
reorder | { measure, play } | The reorder trip for plain DOM: measure the group children before the order changes, play the slide after. |
timing / TRANSITION | (transition?) => Transition | The house curve — 250ms on a custom ease — or instant under prefers-reduced-motion. |
FILL_ATTRIBUTE / SEPARATOR_ATTRIBUTE | string | Mark the filling panel and a separator slot. Sized panels read the first one to find the edge they drag. |
edgeSize | () => number | Thickness of the drag area a bare panel puts on its edge: 8px for a mouse, 20px for a finger. |
coarsePointer / reducedMotion | { get, subscribe } | The two media queries the panels watch, as stores you can read from a component. |
grips | registry | Rect-cached hit testing behind crossings, used by attachSeparator: register, at, mark, partners, state, invalidate, subscribe. |