motion-panels
Docs / Reactv0.1.0

Resizable panels, animated with Motion

A framework-agnostic core with a React adapter on top. Unstyled, no layout library, and separators are optional — a sized panel drags by its own edge. Everything on this page is the published package running live; the workspace beside this text resizes.

Quick start
pnpm add motion-panels motion
Files160px
  • src
  • group.tsx
  • panel.tsx
  • separator.tsx
  • index.ts
workspace.tsx
1export function Workspace() {
2 const [w, setW] = useState(288)
3
4 return (
5 <Group>
6 <Panel size={w}>
7 <Files />
8 </Panel>
9 <Separator />
10 <Panel pin>
11 <Editor />
12 </Panel>
13 </Group>
14 )
15}
Output104px
  • $ pnpm add motion-panels
  • Packages: +1
  • done in 1.2s
Agent232px
  • you

    Make the file tree collapsible.

  • agent

    Added onCollapsedChange to the Files panel.

  • agent

    Enter on its separator toggles it now.

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.

shell
pnpm add motion-panels motion       # the core alone
pnpm add motion-panels motion react # with the React adapter
tsx
import { createPanel, const createPanelGroup: (orientation?: Orientation) => PanelGroupcreatePanelGroup } from 'motion-panels'
const createPanel: (group: PanelGroup, initial: PanelOptions) => PanelController
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.

Files240px
  • index.tsx
  • group.tsx
  • panel.tsx
  • separator.tsx
workspace.tsx
  1. export function Workspace() {
  2. const [width, setWidth] = useState(240)
  3. return (
  4. <Group orientation="horizontal">
  5. <Panel size={width} onSizeChange={setWidth} />
  6. <Separator />
  7. <Panel pin />
  8. </Group>
  9. )
  10. }
tsx
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.

Files240px
  • index.tsx
  • group.tsx
  • panel.tsx
  • separator.tsx
workspace.tsx
  1. export function Workspace() {
  2. const [width, setWidth] = useState(240)
  3. return (
  4. <Group orientation="horizontal">
  5. <Panel size={width} onSizeChange={setWidth} />
  6. <Separator />
  7. <Panel pin />
  8. </Group>
  9. )
  10. }
KeyDoes
ArrowsGrow or shrink by 10px, along the group axis
Shift + arrowsThe same, by 50px
Home / EndJump to minSize or maxSize
EnterToggle collapsed (needs onCollapsedChange)
Double-clickReset to the size the panel mounted with
tsx
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.

workspace.tsx
  1. export function Workspace() {
  2. const [width, setWidth] = useState(240)
  3. return (
  4. <Group orientation="horizontal">
  5. <Panel size={width} onSizeChange={setWidth} />
  6. <Separator />
  7. <Panel pin />
  8. </Group>
  9. )
  10. }
Output120px
  1. $ pnpm add motion-panels
  2. Packages: +1
  3. done in 1.2s
tsx
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.

Navigator260px
  • Group
  • Panel
  • Separator
  • useGroup
workspace.tsx
  1. export function Workspace() {
  2. const [width, setWidth] = useState(240)
  3. return (
  4. <Group orientation="horizontal">
  5. <Panel size={width} onSizeChange={setWidth} />
  6. <Separator />
  7. <Panel pin />
  8. </Group>
  9. )
  10. }
<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] }}
/>
tsx
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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 | undefined
Properties, 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 | undefined
Values 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) | undefined
Default 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.
@public
bounce
: 0.4, ValueTransition.duration?: number | undefined
The duration of the tween animation. Set to `0.3` by default, 0r `0.8` if animating a series of keyframes.
@public
duration
: 0.7, ValueTransition.type?: AnimationGeneratorType | undefined
Type of animation to use. - "tween": Duration-based animation with ease curve - "spring": Physics or duration-based spring animation - false: Use an instant animation
type
: '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.

Sidebar240px
  • 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.

tsx
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.

Files200px
  • index.tsx
  • group.tsx
  • panel.tsx
  • separator.tsx
workspace.tsx
  1. export function Workspace() {
  2. const [width, setWidth] = useState(240)
  3. return (
  4. <Group orientation="horizontal">
  5. <Panel size={width} onSizeChange={setWidth} />
  6. <Separator />
  7. <Panel pin />
  8. </Group>
  9. )
  10. }
Console100px
  1. $ pnpm add motion-panels
  2. Packages: +1
  3. done in 1.2s
tsx
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.

Files140px
  • index.tsx
  • group.tsx
  • panel.tsx
  • separator.tsx
workspace.tsx
  1. export function Workspace() {
  2. const [width, setWidth] = useState(240)
  3. return (
  4. <Group orientation="horizontal">
  5. <Panel size={width} onSizeChange={setWidth} />
  6. <Separator />
  7. <Panel pin />
  8. </Group>
  9. )
  10. }
Outline120px
  • Group
  • Panel
  • Separator
  • useGroup
Terminal90px
  1. $ pnpm add motion-panels
  2. Packages: +1
  3. 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.

Files180px
  • index.tsx
  • group.tsx
  • panel.tsx
  • separator.tsx
workspace.tsx
  1. export function Workspace() {
  2. const [width, setWidth] = useState(240)
  3. return (
  4. <Group orientation="horizontal">
  5. <Panel size={width} onSizeChange={setWidth} />
  6. <Separator />
  7. <Panel pin />
  8. </Group>
  9. )
  10. }
Outline180px
  • Group
  • Panel
  • Separator
  • useGroup
tsx
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
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.

tsx
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: ObjectConstructor
Provides 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.
@paramtarget The target object to copy to.@paramsource The source object from which to copy properties.
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): void
The **`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: Math
An intrinsic object that provides basic mathematics functionality and constants.
Math
.Math.max(...values: number[]): number
Returns the larger of a set of supplied numeric expressions.
@paramvalues Numeric expressions to be evaluated.
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: number
The **`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: number
The **`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): void
The **`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: number
The **`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: number
The **`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: number
The **`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.

ElementWhat you give it
group rootdisplay: flex, flex-direction from axes.direction, and overflow: clip on the outermost group.
filling panelThe 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 panelflex-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 contentflex-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.
separatorrole='separator', tabIndex, aria-orientation from axes.separator, and aria-valuenow from controller.target.
pinned fillA 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.

tsx
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): boolean
The **`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: number
Gets 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.

css
[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

PropTypeDoes
orientation'horizontal' | 'vertical'Axis the panels split on. Groups nest.
transitionTransitionTiming of the reorder trip: keyed children rendered in a new order travel there. Defaults to the house curve.

Panel

PropTypeDoes
sizenumberCurrent size in pixels. Omit it and the panel fills what is left.
onSizeChange(size: number) => voidCalled with the new size as a drag or key press lands.
resetSizenumberSize a double-click on the separator resets to. Defaults to the size the panel mounted with.
minSize / maxSizenumberDrag and keyboard bounds. Max defaults to the group extent.
collapsedbooleanFolds the panel to zero. Dragging below half of minSize sets it too.
onCollapsedChange(collapsed: boolean) => voidRequired for drag-to-collapse and Enter-to-toggle.
transitionTransitionMotion transition for the fold. Defaults to a 250ms ease.
initial / animate / exitmotion propsApplied to the content while the panel folds.
pinbooleanFilling 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.

PropTypeDoes
createPanelGroup(orientation?) => PanelGroupThe shared registry: axes, the filling panel motion values, the sized panels by side.
createPanel(group, options) => PanelControllerOne panel state machine: bounds, drag, keyboard, folds, collapse.
controller.attach(element) => () => voidReads the panel place in the group and registers it. Returns the detach.
controller.sync(options) => voidFeeds 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) => voidArrows, Shift, PageUp / PageDown, Home / End, Enter. Takes any KeyboardEvent.
gripsregistryRect-cached hit testing behind crossings: register, at, mark, partners.