Skip to content
motion-panels

Resizable panels, animated with Motion

Drag a panel by its edge, or add a separator. No styling, no layout library. Try it — the panels next to this text are the real thing.

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: <S extends Size>(group: PanelGroup, initial: PanelOptions<S>) => PanelController<S>
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup,
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'

shadcn

The registry ships one item: a styled separator over the same components, written into your project as components/ui/motion-panels.tsx and yours to edit. It pulls the package in for you.

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. }
shell
npx shadcn@latest add https://motion-panels.letstri.dev/r/motion-panels.json
tsx
import { PanelGroup, Panel, PanelSeparator } from '@/components/ui/motion-panels'
import { useState } from 'react'

export function Layout() {
  const [width, setWidth] = useState(240)

  return (
    <PanelGroup orientation="horizontal">
      <Panel size={width} minSize={160} maxSize={420} onSizeChange={setWidth}>
        <FileTree />
      </Panel>
      <PanelSeparator withHandle />
      <Panel>
        <Editor />
      </Panel>
    </PanelGroup>
  )
}

Quick start

A group is a flex container. A panel with a size holds it, a panel without one fills what is left. That is the whole layout. A size is pixels, or a percentage of the group that follows it as it resizes; onSizeChange reports whichever form it was given. No separator here — a sized panel is draggable by the edge facing the filling panel, so grab the seam below and pull. Hover any identifier in a snippet to read its real type.

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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
} from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)
Returns a stateful value, and a function to update it.
@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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup orientation?: "horizontal" | "vertical" | undefinedorientation="horizontal"> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: numbersize={const width: numberwidth} minSize?: Size | undefinedminSize={160} maxSize?: Size | undefinedmaxSize={420} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setWidth: Dispatch<SetStateAction<number>>setWidth} > <function FileTree(): ReactNodeFileTree /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<function Editor(): ReactNodeEditor /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

Separator

Drop a Separator between two panels and the same split gains a visible grip, keyboard control and double-click reset. It finds the sized panel next to it, resizes that one, and sits centred on the seam without taking space in the flow. It is a focusable [role='separator'] carrying the panel size on aria-valuenow.

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
Page up / Page downThe same, by 50px, without a modifier
Home / EndJump to minSize or maxSize
EnterToggle collapsed (needs onCollapsedChange)
Double-clickReset to defaultSize, or to the size the panel mounted with
tsx
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
export function function Layout(): JSX.ElementLayout() { return ( <const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: 240size={240} minSize?: Size | undefinedminSize={160} maxSize?: Size | undefinedmaxSize={420}>
<function FileTree(): ReactNodeFileTree /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<function Editor(): ReactNodeEditor /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

Orientation

The same split on the other axis. Nothing about the panel or the separator changes — the group decides the axis, the cursor, the separator orientation and which arrow keys grow it.

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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)
Returns a stateful value, and a function to update it.
@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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup orientation?: "vertical" | "horizontal" | undefinedorientation="vertical"> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<function Editor(): ReactNodeEditor /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: numbersize={const height: numberheight} minSize?: Size | undefinedminSize={80} maxSize?: Size | undefinedmaxSize={220} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setHeight: Dispatch<SetStateAction<number>>setHeight}>
<function Output(): ReactNodeOutput /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

Collapsing and folds

A collapsed panel folds to zero, and its content animates with whatever motion props the panel carries, so the fold is yours to design. Pick a preset and toggle. The content is anchored to the edge facing the filling panel, so a slide leans into the fold and originX pins a scale to that same edge. Passing onCollapsedChange also turns on drag-below-half-the-minimum and Enter on the separator.

Navigator260px
  • Group
  • Panel
  • Separator
  • createPanel
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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)
Returns a stateful value, and a function to update it.
@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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: numbersize={const width: numberwidth} minSize?: Size | undefinedminSize={180} collapsed?: boolean | undefinedcollapsed={const collapsed: booleancollapsed} onCollapsedChange?: ((collapsed: boolean) => void) | undefinedonCollapsedChange={const setCollapsed: Dispatch<SetStateAction<boolean>>setCollapsed} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setWidth: Dispatch<SetStateAction<number>>setWidth} initial?: boolean | TargetAndTransition | VariantLabels | 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 }}
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' }}
style?: MotionStyle | undefined
The React DOM `style` prop, enhanced with support for `MotionValue`s and separate `transform` values. ```jsx export const MyComponent = () => { const x = useMotionValue(0) return <motion.div style={{ x, opacity: 1, scale: 0.5 }} /> } ```
style
={{ originX?: MotionValueHelper<AnyResolvedKeyframe | undefined>originX: 1 }}
> <function Navigator(): ReactNodeNavigator /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<function Editor(): ReactNodeEditor /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

Pinning

A filling panel reflows its content on every frame of a fold. A pinned one sizes the content once, up front, and anchors it to the edge that is not moving, so the content holds still while the fold slides the panel edge across it. Toggle the pin off and watch the paragraph rewrap the whole way through.

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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)
Returns a stateful value, and a function to update it.
@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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: numbersize={const width: numberwidth} minSize?: Size | undefinedminSize={160} collapsed?: boolean | undefinedcollapsed={const collapsed: booleancollapsed} onCollapsedChange?: ((collapsed: boolean) => void) | undefinedonCollapsedChange={const setCollapsed: Dispatch<SetStateAction<boolean>>setCollapsed} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setWidth: Dispatch<SetStateAction<number>>setWidth} > <function FileTree(): ReactNodeFileTree /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
pin?: boolean | undefinedpin>
<function Document(): ReactNodeDocument /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

Nesting and intersections

Groups nest: here a vertical split lives inside the filling panel of a horizontal one. Where the two seams meet, press near the crossing and both separators follow the pointer — the cursor turns to move and each one resizes its own panel. Nothing to add: any separator whose grip reaches the pointer joins the drag.

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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)
Returns a stateful value, and a function to update it.
@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: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: numbersize={const sidebar: numbersidebar} minSize?: Size | undefinedminSize={140} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setSidebar: Dispatch<SetStateAction<number>>setSidebar}>
<function FileTree(): ReactNodeFileTree /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup orientation?: "vertical" | "horizontal" | undefinedorientation="vertical"> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<function Editor(): ReactNodeEditor /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: numbersize={const terminal: numberterminal} minSize?: Size | undefinedminSize={60} onSizeChange?: ((size: number) => void) | undefinedonSizeChange={const setTerminal: Dispatch<SetStateAction<number>>setTerminal}>
<function Console(): ReactNodeConsole /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

Depth is not limited. Below, a horizontal split sits in the top panel of a vertical split, which sits in the filling panel of the outer row. Both crossings resize both axes: files with terminal at the left end of the terminal seam, outline with terminal at its right end — two levels apart, and neither knows about the other.

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
  • createPanel
Terminal90px
  1. $ pnpm add motion-panels
  2. Packages: +1
  3. done in 1.2s

Reordering

Grab the dots in a panel header and carry the panel across: the two sides trade places and travel there. Give the group the order it should read and a callback, give each movable panel its value, and the drag is motion's own reorder gesture — the order lives in your state, so it is yours to persist.

Files
  • 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. }
Outline
  • Group
  • Panel
  • Separator
  • createPanel

order: [files, outline]

tsx
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, const Handle: ({ "aria-label": ariaLabel, onKeyDown, onPointerDown, style, type, ...props }: HandleProps) => JSX.Element | nullHandle, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
, const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator } from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)
Returns a stateful value, and a function to update it.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
useState
} from 'react'
const const SIDES: Record<string, ReactNode>SIDES: type Record<K extends keyof any, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefined
Represents all of the things React can render. Where {@link ReactElement } only represents JSX, `ReactNode` represents everything that can be rendered.
@see{@link https://react-typescript-cheatsheet.netlify.app/docs/reference/reactnode/ React TypeScript Cheatsheet}@example```tsx // Typing children type Props = { children: ReactNode } const Component = ({ children }: Props) => <div>{children}</div> <Component>hello</Component> ```@example```tsx // Typing a custom element type Props = { customElement: ReactNode } const Component = ({ customElement }: Props) => <div>{customElement}</div> <Component customElement={<div>hello</div>} /> ```
ReactNode
> = {
files: JSX.Elementfiles: <function Files(): ReactNodeFiles />, outline: JSX.Elementoutline: <function Outline(): ReactNodeOutline />, } export function function Workspace(): JSX.ElementWorkspace() { const [const order: string[]order, const setOrder: Dispatch<SetStateAction<string[]>>setOrder] = useState<string[]>(initialState: string[] | (() => string[])): [string[], Dispatch<SetStateAction<string[]>>] (+1 overload)
Returns a stateful value, and a function to update it.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
useState
(['files', 'outline'])
const const side: (id: string) => JSX.Elementside = (id: stringid: string) => ( <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
Attributes.key?: Key | null | undefinedkey={id: stringid} size: 200size={200} value?: unknownvalue={id: stringid}>
<const Handle: ({ "aria-label": ariaLabel, onKeyDown, onPointerDown, style, type, ...props }: HandleProps) => JSX.Element | nullHandle className?: string | undefinedclassName={const GRIP: stringGRIP}>⠿</const Handle: ({ "aria-label": ariaLabel, onKeyDown, onPointerDown, style, type, ...props }: HandleProps) => JSX.Element | nullHandle> {const SIDES: Record<string, ReactNode>SIDES[id: stringid]} </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
) return ( <const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup order?: string[] | undefinedorder={const order: string[]order} onOrderChange?: ((order: string[]) => void) | undefinedonOrderChange={const setOrder: Dispatch<SetStateAction<string[]>>setOrder}> {const side: (id: string) => JSX.Elementside(const order: string[]order[0])} <const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<function Editor(): ReactNodeEditor /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<const Separator: ({ "aria-label": ariaLabel, end, own, style, tabIndex, transition, ...props }: GripProps) => JSX.ElementSeparator /> {const side: (id: string) => JSX.Elementside(const order: string[]order[1])} </const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

The filling panel carries no value here, and that is the whole rule: a group holds at most one sized panel on each side of it, so the panels that move are the ones you name in order, and no drag can reach an order the group cannot hold. A panel left out stays where it is — the middle card above shows no dots, because Handle renders nothing inside a panel the group does not move. Handles are buttons: focus one and the arrow keys along the group axis move the panel too. Motion takes the drag from there — it snaps the panel back if it lands nowhere and scrolls a long group at the edges — and a right-to-left group carries the panel the way the row reads.

Panels on both edges

Each sized panel finds its own side: one before the filling panel drags on its end edge, one after it on its start edge. Two sized panels around one filling panel need no extra wiring, and again no separators. These two are percentages and stay percentages: a drag reports one back, so they keep following the group.

Files25%
  • 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. }
Outline25%
  • Group
  • Panel
  • Separator
  • createPanel
tsx
import type { type Size = number | `${number}%`Size } from 'motion-panels/react'
import { const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup, 
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
} from 'motion-panels/react'
import { function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)
Returns a stateful value, and a function to update it.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
useState
} from 'react'
export function function Workbench(): JSX.ElementWorkbench() { const [const left: Sizeleft, const setLeft: Dispatch<SetStateAction<Size>>setLeft] = useState<Size>(initialState: Size | (() => Size)): [Size, Dispatch<SetStateAction<Size>>] (+1 overload)
Returns a stateful value, and a function to update it.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
useState
<type Size = number | `${number}%`Size>('25%')
const [const right: Sizeright, const setRight: Dispatch<SetStateAction<Size>>setRight] = useState<Size>(initialState: Size | (() => Size)): [Size, Dispatch<SetStateAction<Size>>] (+1 overload)
Returns a stateful value, and a function to update it.
@version16.8.0@see{@link https://react.dev/reference/react/useState}
useState
<type Size = number | `${number}%`Size>('25%')
return ( <const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> <
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: Sizesize={const left: Sizeleft} minSize?: Size | undefinedminSize="14%" maxSize?: Size | undefinedmaxSize="35%" onSizeChange?: ((size: Size) => void) | undefinedonSizeChange={const setLeft: Dispatch<SetStateAction<Size>>setLeft}>
<function FileTree(): ReactNodeFileTree /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<function Editor(): ReactNodeEditor /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
<
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
size: Sizesize={const right: Sizeright} minSize?: Size | undefinedminSize="14%" maxSize?: Size | undefinedmaxSize="35%" onSizeChange?: ((size: Size) => void) | undefinedonSizeChange={const setRight: Dispatch<SetStateAction<Size>>setRight}>
<function Outline(): ReactNodeOutline /> </
const Panel: {
    <S extends Size>(props: SizedPanelProps<S>): ReactElement;
    (props: AnyPanelProps): ReactElement;
}
Overloads, not one generic signature: `ComponentProps<typeof Panel>` in a wrapper reads the last one, and a lone generic would resolve there to its constraint, handing the wrapper's consumers `Size` instead of the form they passed. The last signature is the shapes side by side, which a call site narrows on its own size.
Panel
>
</const Group: <V>({ children, onOrderChange, order, orientation, reorder: travel, style, transition, ...props }: GroupProps<V>) => JSX.ElementGroup> ) }

Core, without React

The demo below renders no components: it builds a group and a panel from the core and wires them to plain DOM nodes. Same bounds, same folds, same keyboard — drag the seam, or focus the grip and use the arrows. This is the whole surface an adapter for another framework has to cover.

tsx
import { const attachSeparator: (element: HTMLElement, group: PanelGroup, own?: PanelController) => () => voidattachSeparator, const createPanel: <S extends Size>(group: PanelGroup, initial: PanelOptions<S>) => PanelController<S>createPanel, const createPanelGroup: (orientation?: Orientation) => PanelGroupcreatePanelGroup, const FILL_ATTRIBUTE: "data-motion-panels-fill"FILL_ATTRIBUTE } from 'motion-panels'

export function function mountSplit(root: HTMLElement, panel: HTMLElement, fill: HTMLElement, grip: HTMLElement): () => voidmountSplit(root: HTMLElementroot: HTMLElement, panel: HTMLElementpanel: HTMLElement, fill: HTMLElementfill: HTMLElement, grip: HTMLElementgrip: HTMLElement) {
  const const group: PanelGroupgroup = function createPanelGroup(orientation?: Orientation): PanelGroupcreatePanelGroup('horizontal')

  var Object: 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: PanelController<number>controller.PanelController<number>.sync(options: PanelOptions<number>, mounting?: boolean): voidsync({ ...
const base: {
    minSize: number;
    maxSize: number;
    onSizeChange: (next: number) => void;
}
base
, PanelOptions<number>.size: numbersize })
}, } const const controller: PanelController<number>controller = createPanel<number>(group: PanelGroup, initial: PanelOptions<number>): PanelController<number>createPanel(const group: PanelGroupgroup, { ...
const base: {
    minSize: number;
    maxSize: number;
    onSizeChange: (next: number) => void;
}
base
, PanelOptions<number>.size: numbersize })
const const detach: () => voiddetach = const controller: PanelController<number>controller.PanelController<number>.attach: (element: HTMLElement) => () => voidattach(panel: HTMLElementpanel) const controller: PanelController<number>controller.
PanelController<number>.motion: {
    content: MotionValue<number>;
    size: MotionValue<number>;
}
motion
.size: MotionValue<number>size.MotionValue<number>.on<"change">(eventName: "change", callback: (latestValue: number) => void): VoidFunctionon('change', (value: numbervalue) => {
panel: HTMLElementpanel.ElementCSSInlineStyle.style: CSSStyleDeclaration
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)
style
.CSSStyleDeclaration.width: string
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/width)
width
= `${var Math: 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
const const detachGrip: () => voiddetachGrip = function attachSeparator(element: HTMLElement, group: PanelGroup, own?: PanelController): () => voidattachSeparator(grip: HTMLElementgrip, const group: PanelGroupgroup, const controller: PanelController<number>controller) return () => { const detachGrip: () => voiddetachGrip() const detach: () => voiddetach() const controller: PanelController<number>controller.PanelController<number>.destroy: () => voiddestroy() } }

attach reads the panel's place in the group — which side of the filling panel it sits on, and so which edge drags — and returns the detach. attachSeparator wires the separator: pointer drags and crossings, keyboard, double-click reset, and the live aria-value and data attributes. sync feeds the panel new options on every state change, the same calls the React adapter makes in layout effects. Everything else is state you already own.

What every element needs

The core owns numbers, never nodes. It reads one attribute and hands back motion values and a state object; laying the flexbox out is the adapter's half of the deal. This is that half, in full.

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.
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, touch-action: none, and attachSeparator. It writes aria-valuenow, data-resizing and data-crossing itself.
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 reorders

The demo above stops at a drag. A fold is one sync away, and a reorder is two calls around the DOM change: measure the children before, play the trip after.

tsx
import type { interface PanelController<S extends Size = Size>PanelController, PanelGroup, interface PanelOptions<S extends Size = Size>PanelOptions } from 'motion-panels'
import { 
const reorder: {
    measure: (root: HTMLElement, axes: Axes) => Map<Element, number>;
    play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => void;
}
reorder
} from 'motion-panels'
export function function wireExtras(controller: PanelController, options: PanelOptions, content: HTMLElement, toggle: HTMLElement): voidwireExtras(controller: PanelController<Size>controller: interface PanelController<S extends Size = Size>PanelController, options: PanelOptions<Size>options: interface PanelOptions<S extends Size = Size>PanelOptions, content: HTMLElementcontent: HTMLElement, toggle: HTMLElementtoggle: HTMLElement) { let let collapsed: booleancollapsed = false toggle: HTMLElementtoggle.HTMLElement.addEventListener<"click">(type: "click", listener: (this: HTMLElement, ev: PointerEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)
The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target. [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener) The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target. [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)
addEventListener
('click', () => {
let collapsed: booleancollapsed = !let collapsed: booleancollapsed controller: PanelController<Size>controller.PanelController<Size>.sync(options: PanelOptions<Size>, mounting?: boolean): voidsync({ ...options: PanelOptions<Size>options, PanelOptions<Size>.collapsed?: boolean | undefinedcollapsed }) }) controller: PanelController<Size>controller.
PanelController<Size>.motion: {
    content: MotionValue<number>;
    size: MotionValue<number>;
}
motion
.content: MotionValue<number>content.MotionValue<number>.on<"change">(eventName: "change", callback: (latestValue: number) => void): VoidFunctionon('change', (value: numbervalue) => {
content: HTMLElementcontent.ElementCSSInlineStyle.style: CSSStyleDeclaration
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)
style
.CSSStyleDeclaration.width: string
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/width)
width
= `${value: numbervalue}px`
}) } export function function move(root: HTMLElement, axes: PanelGroup["axes"], change: () => void): voidmove(root: HTMLElementroot: HTMLElement, axes: Axesaxes: PanelGroup['axes'], change: () => voidchange: () => void) { const const before: Map<Element, number>before =
const reorder: {
    measure: (root: HTMLElement, axes: Axes) => Map<Element, number>;
    play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => void;
}
reorder
.measure: (root: HTMLElement, axes: Axes) => Map<Element, number>measure(root: HTMLElementroot, axes: Axesaxes)
change: () => voidchange()
const reorder: {
    measure: (root: HTMLElement, axes: Axes) => Map<Element, number>;
    play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => void;
}
reorder
.play: (before: Map<Element, number>, axes: Axes, transition?: Transition) => voidplay(const before: Map<Element, number>before, axes: Axesaxes)
}

Styling

Nothing ships styled. A separator is [role='separator'] with aria-orientation, centred on the seam it drags and taking no space in the flow, so give it a width and it straddles the boundary on its own. It carries data-crossing while the pointer hovers a crossing it would drag from, and data-resizing from press to release. A sized panel with no Separator of its own renders one anyway as the drag area on its edge — that one carries data-motion-panels-edge and should stay invisible until it is focused: it is still a tab stop, so give it a focus-visible outline.

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;
}

[role='separator'][data-motion-panels-edge]:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: -2px;
}

API

Every component forwards the rest of its props to a motion div.

Group

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.
reorderboolean (default true)Pass false and reordered children jump to their new place instead of travelling, with nothing measured on the way.
orderV[]The order the movable panels read in, one entry per panel that may move. A panel left out of it stays where it is, which is how the filling panel keeps its place.
onOrderChange(order: V[]) => voidThe new order, after a drag or an arrow key carried a panel past its neighbour. Hand it straight to your setState: render the panels in that order and they travel there. With an order set, the group hands the trip to motion and leaves its own alone.

Panel

PropTypeDoes
sizenumber | stringCurrent size, in pixels or as a percentage of the group extent. A percentage follows the group as it resizes. Omit it and the panel fills what is left.
onFoldEnd() => voidThe panel finished travelling to its size — a fold, an unfold, or the settle after a drag. Sequence work on it instead of guessing at a duration: wait for the fold before reordering, and nothing moves while a panel is still closing.
onSizeChange(size: number | string) => voidCalled with the new size as a drag or key press lands, in the form size was given: a number reports pixels, a percent string reports a percentage. A wrapper component reading ComponentProps sees the pixel form; one that forwards percentages takes PanelProps<Size>.
defaultSizenumber | stringSize a double-click on the separator resets to. Defaults to the size the panel mounted with.
minSize / maxSizenumber | stringDrag and keyboard bounds, in pixels or as a percentage of the group extent. Both clamp to the room the other panels leave, and max defaults to all of it.
overshootboolean | number (default 22)A drag that reaches minSize or maxSize keeps stretching a little past it, then springs back on release, so the edge shows it has run out rather than looking stuck. A number sets how far that stretch reaches, in pixels; false or 0 stops the drag dead at the bound.
collapsedbooleanFolds the panel to zero. Dragging below half of minSize sets it too, once onCollapsedChange is there to hear it.
onCollapsedChange(collapsed: boolean) => voidRequired for drag-to-collapse and Enter-to-toggle.
keepMountedboolean (default true)Keeps the content mounted once the panel has been open, clipped at zero while collapsed, so reopening costs no mount: the fold animates it between initial (or exit) and animate. A panel that has never been open mounts nothing, and one that mounts open skips its entrance. Pass false to unmount on every close instead.
transitionTransitionTiming of the fold. Defaults to the house curve.
initial / animate / exitmotion propsApplied to the content while the panel folds.
valueunknownThis panel entry in the group order. Given one, the panel becomes a motion reorder item that a Handle inside it can carry. Without one it never moves.
pinbooleanFilling panels only. Lays the content out once per fold instead of once per frame.

Handle

The grip that moves a panel. Renders a motion button, so anything inside it is yours, and aria-label defaults to the panel value — 'Move files' for a panel valued files — or to 'Move panel' where the value is not a string or a number. Put one anywhere inside a panel that carries a value: pressing it starts motion's reorder drag, and the arrow keys along the group axis move the panel a place at a time. Inside a panel with no value, or a group with no order, it renders nothing at all. While a panel is carried, its group takes no pointer events, so nothing lights up under it.

Separator

No props of its own beyond transition; the rest reaches a motion div, and aria-label defaults to 'Resize panel'. Optional: rendered between two panels it resizes the sized one and sits over its edge without taking flow space, and a flex gap on the group opens on both sides of it, so the seam is twice the gap with the grip in its middle. It keeps aria-valuenow, aria-valuetext and aria-valuemin current, adds aria-valuemax once the panel has a maxSize, and marks itself with data-resizing and data-crossing.

motion-panels

The core, for an adapter or for plain DOM. Nothing here imports React.

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.bounds() => { min, max }The bounds in pixels, percentages resolved and clamped to the room the other panels leave.
controller.targetnumberThe size the panel is settling on, 0 while collapsed. What a separator reports as aria-valuenow.
controller.reset() => voidCalls onSizeChange with defaultSize, or the size the panel mounted with. The double-click.
controller.state{ bare, dragging, end, folding }Read it through subscribe. A frozen object, replaced only when it changes.
controller.subscribe(listener) => () => voidFires when the state or the target changes. This is the store useSyncExternalStore reads.
controller.destroy() => voidDrops the listeners and any body lock the panel still holds. Call it after the detach.
controller.drag{ start, move, end, cancel }Pointer drag, in the units your gesture layer reports.
controller.resizeByKey(event) => voidArrows, Shift, PageUp / PageDown, Home / End, Enter. Takes any KeyboardEvent.
attachSeparator(element, group, own?) => () => voidWires a separator element: pointer drags and crossings, keyboard, double-click reset, live aria-value and data attributes. Without own it resizes the panel its slot sits beside. Returns the detach.
reorder{ measure, play }The reorder trip for plain DOM: measure the group children before the order changes, play the slide after.
timing / TRANSITION(transition?) => TransitionThe house curve — 250ms on a custom ease — or instant under prefers-reduced-motion.
FILL_ATTRIBUTE / SEPARATOR_ATTRIBUTEstringMark the filling panel and a separator slot. Sized panels read the first one to find the edge they drag.
edgeSize() => numberThickness of the drag area a bare panel puts on its edge: 8px for a mouse, 20px for a finger.
coarsePointer / reducedMotion{ get, subscribe }The two media queries the panels watch, as stores you can read from a component.
gripsregistryRect-cached hit testing behind crossings, used by attachSeparator: register, at, mark, partners, state, invalidate, subscribe.