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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel, const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator } from 'motion-panels/react'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. 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel } 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup orientation?: "horizontal" | "vertical" | undefinedorientation="horizontal">
<const Panel: (props: PanelProps) => JSX.ElementPanel
size: numbersize={const width: numberwidth}
minSize?: number | undefinedminSize={160}
maxSize?: number | undefinedmaxSize={420}
onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setWidth: Dispatch<SetStateAction<number>>setWidth}
>
<function FileTree(): ReactNodeFileTree />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Panel: (props: PanelProps) => JSX.ElementPanel>
<function Editor(): ReactNodeEditor />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => 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 |
Home / End | Jump to minSize or maxSize |
Enter | Toggle collapsed (needs onCollapsedChange) |
Double-click | Reset to the size the panel mounted with |
import { const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel, const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator } from 'motion-panels/react'
export function function Layout(): JSX.ElementLayout() {
return (
<const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup>
<const Panel: (props: PanelProps) => JSX.ElementPanel size: numbersize={240} minSize?: number | undefinedminSize={160} maxSize?: number | undefinedmaxSize={420}>
<function FileTree(): ReactNodeFileTree />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator />
<const Panel: (props: PanelProps) => JSX.ElementPanel>
<function Editor(): ReactNodeEditor />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel, const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup orientation?: "vertical" | "horizontal" | undefinedorientation="vertical">
<const Panel: (props: PanelProps) => JSX.ElementPanel>
<function Editor(): ReactNodeEditor />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator />
<const Panel: (props: PanelProps) => JSX.ElementPanel size: numbersize={const height: numberheight} minSize?: number | undefinedminSize={80} maxSize?: number | undefinedmaxSize={220} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setHeight: Dispatch<SetStateAction<number>>setHeight}>
<function Output(): ReactNodeOutput />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => 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
- useGroup
- 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel, const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup>
<const Panel: (props: PanelProps) => JSX.ElementPanel
size: numbersize={const width: numberwidth}
minSize?: number | 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}
MotionNodeAnimationOptions.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 }}
MotionNodeAnimationOptions.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' }}
MotionProps.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: (props: PanelProps) => JSX.ElementPanel>
<const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator />
<const Panel: (props: PanelProps) => JSX.ElementPanel>
<function Editor(): ReactNodeEditor />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel, const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup>
<const Panel: (props: PanelProps) => JSX.ElementPanel
size: numbersize={const width: numberwidth}
minSize?: number | 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: (props: PanelProps) => JSX.ElementPanel>
<const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator />
<const Panel: (props: PanelProps) => JSX.ElementPanel pin?: boolean | undefinedpin>
<function Document(): ReactNodeDocument />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel, const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => 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: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup>
<const Panel: (props: PanelProps) => JSX.ElementPanel size: numbersize={const sidebar: numbersidebar} minSize?: number | undefinedminSize={140} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setSidebar: Dispatch<SetStateAction<number>>setSidebar}>
<function FileTree(): ReactNodeFileTree />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator />
<const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup orientation?: "vertical" | "horizontal" | undefinedorientation="vertical">
<const Panel: (props: PanelProps) => JSX.ElementPanel>
<function Editor(): ReactNodeEditor />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Separator: ({ "aria-label": ariaLabel, own, style, tabIndex, transition, ...props }: SeparatorProps) => JSX.ElementSeparator />
<const Panel: (props: PanelProps) => JSX.ElementPanel size: numbersize={const terminal: numberterminal} minSize?: number | undefinedminSize={60} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setTerminal: Dispatch<SetStateAction<number>>setTerminal}>
<function Console(): ReactNodeConsole />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup>
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => 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
- useGroup
- $ pnpm add motion-panels
- Packages: +1
- done in 1.2s
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.
- 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
- useGroup
import { const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup, const Panel: (props: PanelProps) => JSX.ElementPanel } 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: numberleft, const setLeft: Dispatch<SetStateAction<number>>setLeft] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(180)
const [const right: numberright, const setRight: Dispatch<SetStateAction<number>>setRight] = useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>] (+1 overload)Returns a stateful value, and a function to update it.useState(180)
return (
<const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => JSX.ElementGroup>
<const Panel: (props: PanelProps) => JSX.ElementPanel size: numbersize={const left: numberleft} minSize?: number | undefinedminSize={120} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setLeft: Dispatch<SetStateAction<number>>setLeft}>
<function FileTree(): ReactNodeFileTree />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Panel: (props: PanelProps) => JSX.ElementPanel>
<function Editor(): ReactNodeEditor />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
<const Panel: (props: PanelProps) => JSX.ElementPanel size: numbersize={const right: numberright} minSize?: number | undefinedminSize={120} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setRight: Dispatch<SetStateAction<number>>setRight}>
<function Outline(): ReactNodeOutline />
</const Panel: (props: PanelProps) => JSX.ElementPanel>
</const Group: ({ children, orientation, style, transition, ...props }: GroupProps) => 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 createPanel: (group: PanelGroup, initial: PanelOptions) => PanelControllercreatePanel, 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: PanelControllercontroller.PanelController.sync: (options: PanelOptions) => voidsync({ ...const base: {
minSize: number;
maxSize: number;
onSizeChange: (next: number) => void;
}
base, PanelOptions.size: numbersize })
},
}
const const controller: PanelControllercontroller = function createPanel(group: PanelGroup, initial: PanelOptions): PanelControllercreatePanel(const group: PanelGroupgroup, { ...const base: {
minSize: number;
maxSize: number;
onSizeChange: (next: number) => void;
}
base, PanelOptions.size: numbersize })
const const detach: () => voiddetach = const controller: PanelControllercontroller.PanelController.attach: (element: HTMLElement) => () => voidattach(panel: HTMLElementpanel)
const controller: PanelControllercontroller.PanelController.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
grip: HTMLElementgrip.HTMLElement.addEventListener<"pointerdown">(type: "pointerdown", 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)addEventListener('pointerdown', (event: PointerEventevent) => {
const const origin: {
x: number;
y: number;
}
origin = { x: numberx: event: PointerEventevent.MouseEvent.clientX: numberThe **`clientX`** read-only property of the MouseEvent interface provides the horizontal coordinate within the application's viewport at which the event occurred (as opposed to the coordinate within the page).
[MDN Reference](https://developer.mozilla.org/docs/Web/API/MouseEvent/clientX)clientX, y: numbery: event: PointerEventevent.MouseEvent.clientY: numberThe **`clientY`** read-only property of the MouseEvent interface provides the vertical coordinate within the application's viewport at which the event occurred (as opposed to the coordinate within the page).
[MDN Reference](https://developer.mozilla.org/docs/Web/API/MouseEvent/clientY)clientY }
grip: HTMLElementgrip.Element.setPointerCapture(pointerId: number): voidThe **`setPointerCapture()`** method of the _capture target_ of future pointer events.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Element/setPointerCapture)setPointerCapture(event: PointerEventevent.PointerEvent.pointerId: numberThe **`pointerId`** read-only property of the event.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/PointerEvent/pointerId)pointerId)
const controller: PanelControllercontroller.PanelController.drag: PanelDragdrag.PanelDrag.start: (cursor?: string) => voidstart()
const const onMove: (move: PointerEvent) => voidonMove = (move: PointerEventmove: PointerEvent) =>
const controller: PanelControllercontroller.PanelController.drag: PanelDragdrag.PanelDrag.move: (offset: {
x: number;
y: number;
}) => void
move({ x: numberx: move: PointerEventmove.MouseEvent.clientX: numberThe **`clientX`** read-only property of the MouseEvent interface provides the horizontal coordinate within the application's viewport at which the event occurred (as opposed to the coordinate within the page).
[MDN Reference](https://developer.mozilla.org/docs/Web/API/MouseEvent/clientX)clientX - const origin: {
x: number;
y: number;
}
origin.x: numberx, y: numbery: move: PointerEventmove.MouseEvent.clientY: numberThe **`clientY`** read-only property of the MouseEvent interface provides the vertical coordinate within the application's viewport at which the event occurred (as opposed to the coordinate within the page).
[MDN Reference](https://developer.mozilla.org/docs/Web/API/MouseEvent/clientY)clientY - const origin: {
x: number;
y: number;
}
origin.y: numbery })
const const onUp: () => voidonUp = () => {
const controller: PanelControllercontroller.PanelController.drag: PanelDragdrag.PanelDrag.end: () => voidend()
grip: HTMLElementgrip.HTMLElement.removeEventListener<"pointermove">(type: "pointermove", listener: (this: HTMLElement, ev: PointerEvent) => any, options?: boolean | EventListenerOptions): void (+1 overload)The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)removeEventListener('pointermove', const onMove: (move: PointerEvent) => voidonMove)
grip: HTMLElementgrip.HTMLElement.removeEventListener<"pointerup">(type: "pointerup", listener: (this: HTMLElement, ev: PointerEvent) => any, options?: boolean | EventListenerOptions): void (+1 overload)The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)removeEventListener('pointerup', const onUp: () => voidonUp)
}
grip: HTMLElementgrip.HTMLElement.addEventListener<"pointermove">(type: "pointermove", 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)addEventListener('pointermove', const onMove: (move: PointerEvent) => voidonMove)
grip: HTMLElementgrip.HTMLElement.addEventListener<"pointerup">(type: "pointerup", 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)addEventListener('pointerup', const onUp: () => voidonUp)
})
grip: HTMLElementgrip.HTMLElement.addEventListener<"keydown">(type: "keydown", listener: (this: HTMLElement, ev: KeyboardEvent) => 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)addEventListener('keydown', (event: KeyboardEventevent) => const controller: PanelControllercontroller.PanelController.resizeByKey: (event: PanelKeyEvent) => voidresizeByKey(event: KeyboardEventevent))
grip: HTMLElementgrip.HTMLElement.addEventListener<"dblclick">(type: "dblclick", listener: (this: HTMLElement, ev: MouseEvent) => 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)addEventListener('dblclick', () => const controller: PanelControllercontroller.PanelController.reset: () => voidreset())
return () => {
const detach: () => voiddetach()
const controller: PanelControllercontroller.PanelController.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. sync feeds it new options on every state change, the same call the React adapter makes in a layout effect. 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. Feed the negative part back as a margin on the dragging edge and an overshoot pulls the layout instead of pushing it. |
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, and aria-valuenow from controller.target. |
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 crossings
The demo above stops at a drag. Two more calls carry the rest: a fold is one sync away, and a crossing is the grip registry handing you the separators that share the point you pressed.
import type { PanelController, PanelOptions } from 'motion-panels'
import { const grips: {
at(point: Point): HTMLElement[];
invalidate: () => void;
mark(key: "crossed" | "held", elements: HTMLElement[]): void;
partners(elements: HTMLElement[], self: HTMLElement | null): PanelController[];
register(element: HTMLElement, controller: PanelController): () => void;
state(element: HTMLElement | null): GripState;
subscribe: (listener: () => void) => () => void;
}
grips } from 'motion-panels'
export function function wireExtras(controller: PanelController, options: PanelOptions, content: HTMLElement, grip: HTMLElement, toggle: HTMLElement): () => voidwireExtras(controller: PanelControllercontroller: PanelController, options: PanelOptionsoptions: PanelOptions, content: HTMLElementcontent: HTMLElement, grip: HTMLElementgrip: 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)addEventListener('click', () => {
let collapsed: booleancollapsed = !let collapsed: booleancollapsed
controller: PanelControllercontroller.PanelController.sync: (options: PanelOptions) => voidsync({ ...options: PanelOptionsoptions, PanelOptions.collapsed?: boolean | undefinedcollapsed })
})
controller: PanelControllercontroller.PanelController.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`
})
const const stop: () => voidstop = controller: PanelControllercontroller.PanelController.subscribe: (listener: () => void) => () => voidsubscribe(() => {
grip: HTMLElementgrip.Element.toggleAttribute(qualifiedName: string, force?: boolean): booleanThe **`toggleAttribute()`** method of the present and adding it if it is not present) on the given element.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Element/toggleAttribute)toggleAttribute('data-resizing', controller: PanelControllercontroller.PanelController.state: PanelStatestate.PanelState.dragging: booleandragging)
})
const const unregister: () => voidunregister = const grips: {
at(point: Point): HTMLElement[];
invalidate: () => void;
mark(key: "crossed" | "held", elements: HTMLElement[]): void;
partners(elements: HTMLElement[], self: HTMLElement | null): PanelController[];
register(element: HTMLElement, controller: PanelController): () => void;
state(element: HTMLElement | null): GripState;
subscribe: (listener: () => void) => () => void;
}
grips.function register(element: HTMLElement, controller: PanelController): () => voidregister(grip: HTMLElementgrip, controller: PanelControllercontroller)
grip: HTMLElementgrip.HTMLElement.addEventListener<"pointerdown">(type: "pointerdown", 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)addEventListener('pointerdown', (event: PointerEventevent) => {
const grips: {
at(point: Point): HTMLElement[];
invalidate: () => void;
mark(key: "crossed" | "held", elements: HTMLElement[]): void;
partners(elements: HTMLElement[], self: HTMLElement | null): PanelController[];
register(element: HTMLElement, controller: PanelController): () => void;
state(element: HTMLElement | null): GripState;
subscribe: (listener: () => void) => () => void;
}
grips.invalidate: () => voidinvalidate()
const const crossed: HTMLElement[]crossed = const grips: {
at(point: Point): HTMLElement[];
invalidate: () => void;
mark(key: "crossed" | "held", elements: HTMLElement[]): void;
partners(elements: HTMLElement[], self: HTMLElement | null): PanelController[];
register(element: HTMLElement, controller: PanelController): () => void;
state(element: HTMLElement | null): GripState;
subscribe: (listener: () => void) => () => void;
}
grips.function at(point: Point): HTMLElement[]at(event: PointerEventevent)
const const partners: PanelController[]partners = const grips: {
at(point: Point): HTMLElement[];
invalidate: () => void;
mark(key: "crossed" | "held", elements: HTMLElement[]): void;
partners(elements: HTMLElement[], self: HTMLElement | null): PanelController[];
register(element: HTMLElement, controller: PanelController): () => void;
state(element: HTMLElement | null): GripState;
subscribe: (listener: () => void) => () => void;
}
grips.function partners(elements: HTMLElement[], self: HTMLElement | null): PanelController[]partners(const crossed: HTMLElement[]crossed, grip: HTMLElementgrip)
const const cursor: "move" | undefinedcursor = const partners: PanelController[]partners.Array<PanelController>.length: numberGets or sets the length of the array. This is a number one higher than the highest index in the array.length > 0 ? 'move' : var undefinedundefined
const grips: {
at(point: Point): HTMLElement[];
invalidate: () => void;
mark(key: "crossed" | "held", elements: HTMLElement[]): void;
partners(elements: HTMLElement[], self: HTMLElement | null): PanelController[];
register(element: HTMLElement, controller: PanelController): () => void;
state(element: HTMLElement | null): GripState;
subscribe: (listener: () => void) => () => void;
}
grips.function mark(key: "crossed" | "held", elements: HTMLElement[]): voidmark('held', const crossed: HTMLElement[]crossed)
controller: PanelControllercontroller.PanelController.drag: PanelDragdrag.PanelDrag.start: (cursor?: string) => voidstart(const cursor: "move" | undefinedcursor)
for (const const partner: PanelControllerpartner of const partners: PanelController[]partners) {
const partner: PanelControllerpartner.PanelController.drag: PanelDragdrag.PanelDrag.start: (cursor?: string) => voidstart(const cursor: "move" | undefinedcursor)
}
})
return () => {
const unregister: () => voidunregister()
const stop: () => voidstop()
}
}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.
[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;
}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. |
Panel
| Prop | Type | Does |
|---|---|---|
size | number | Current size in pixels. Omit it and the panel fills what is left. |
onSizeChange | (size: number) => void | Called with the new size as a drag or key press lands. |
resetSize | number | Size a double-click on the separator resets to. Defaults to the size the panel mounted with. |
minSize / maxSize | number | Drag and keyboard bounds. Max defaults to the group extent. |
collapsed | boolean | Folds the panel to zero. Dragging below half of minSize sets it too. |
onCollapsedChange | (collapsed: boolean) => void | Required for drag-to-collapse and Enter-to-toggle. |
transition | Transition | Motion transition for the fold. Defaults to a 250ms ease. |
initial / animate / exit | motion props | Applied to the content while the panel folds. |
pin | boolean | Filling panels only. Lays the content out once per fold instead of once per frame. |
Separator
No props of its own beyond transition. Optional: rendered between two panels it resizes the sized one, sits over its edge without taking flow space, and puts the panel size on aria-valuenow.
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.state | { bare, dragging, end, folding } | Read it through subscribe. A frozen object, replaced only when it changes. |
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. |
grips | registry | Rect-cached hit testing behind crossings: register, at, mark, partners. |