useDraggableResizable

Move and resize engine for a fixed, bottom-right-anchored floating panel, with geometry saved in localStorage.

useDraggableResizable powers DraggableResizableModal. Use it to build a custom floating surface. The panel is positioned with position: fixed and right / bottom offsets. During a gesture, the hook writes motion values directly, so dragging and resizing do not re-render React.

Import#

typescript
import { useDraggableResizable, computeNextGeometry } from "@reactberry/system";
import type {
  ResizeEdge,
  PanelGeometry,
  DraggableResizableBounds,
  DraggableResizableOptions,
} from "@reactberry/system";

Usage#

typescript
"use client";

import { useCallback } from "react";
import { motion } from "motion/react";
import { useDraggableResizable } from "@reactberry/system";

export default function FloatingPanel({ children }: { children: React.ReactNode }) {
  const getDefaults = useCallback(() => ({ width: 400, height: 300, right: 24, bottom: 24 }), []);
  const { startMove, startResize, rightMV, bottomMV, widthMV, heightMV } = useDraggableResizable({
    storageKey: "floating-panel",
    getDefaults,
  });

  return (
    <motion.div style={{ position: "fixed", right: rightMV, bottom: bottomMV, width: widthMV, height: heightMV }}>
      <div onPointerDown={startMove()} style={{ cursor: "grab" }}>Drag me</div>
      {children}
      <div onPointerDown={startResize("se")} style={{ position: "absolute", right: 0, bottom: 0, width: 12, height: 12 }} />
    </motion.div>
  );
}

Examples#

Resize from any edge#

Attach startResize(edge) to a handle on each edge and corner, and pass bounds to change the minimum size. Hold Alt or Shift while resizing to resize around the centre.

API#

typescript
function useDraggableResizable(options: DraggableResizableOptions): {
  geometry: PanelGeometry;
  isResizing: boolean;
  isMoving: boolean;
  startResize: (edge: ResizeEdge, onCommit?: (g: PanelGeometry) => void) => (e: React.PointerEvent) => void;
  startMove: (onCommit?: (g: PanelGeometry) => void) => (e: React.PointerEvent) => void;
  resetPanel: () => void;
  rightMV: MotionValue<number>;
  bottomMV: MotionValue<number>;
  widthMV: MotionValue<number>;
  heightMV: MotionValue<number>;
}

PanelGeometry is { width, height, right, bottom }, all in pixels.

Parameters#

PropTypeDefaultDescription
options.storageKey*
string
—

localStorage key for the saved geometry.

options.getDefaults*
() => PanelGeometry
—

Returns the geometry used when nothing valid is saved, and by resetPanel.

options.bounds
Partial<DraggableResizableBounds>
—

Overrides for the bounds below.

bounds.minWidth
number
320

Minimum width in pixels.

bounds.minHeight
number
240

Minimum height in pixels.

bounds.maxWidthVw
number
0.98

Maximum width as a fraction of the viewport width.

bounds.maxHeightVh
number
0.98

Maximum height as a fraction of the viewport height.

bounds.guard
number
8

Minimum gap in pixels to the left and top viewport edges.

Returns#

PropTypeDefaultDescription
startMove
(onCommit?) => (event: React.PointerEvent) => void
—

Returns a pointerdown handler that moves the panel. Only the left mouse button starts a move.

startResize
(edge: ResizeEdge, onCommit?) => (event: React.PointerEvent) => void
—

Returns a pointerdown handler that resizes from edge ("n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw").

rightMV
MotionValue<number>
—

Right offset. Bind it to the panel's style.

bottomMV
MotionValue<number>
—

Bottom offset. Bind it to the panel's style.

widthMV
MotionValue<number>
—

Width. Bind it to the panel's style.

heightMV
MotionValue<number>
—

Height. Bind it to the panel's style.

geometry
PanelGeometry
—

The last committed geometry.

isMoving
boolean
—

true during a move.

isResizing
boolean
—

true during a resize.

resetPanel
() => void
—

Sets geometry back to getDefaults().

When a gesture ends, the final geometry is passed to onCommit if given; otherwise it becomes geometry and is saved to localStorage.

computeNextGeometry#

typescript
function computeNextGeometry(args: {
  edge: ResizeEdge;
  start: PanelGeometry & { x: number; y: number };
  dx: number;
  dy: number;
  alt: boolean;
  shift: boolean;
  viewport: { w: number; h: number };
  bounds: DraggableResizableBounds;
}): PanelGeometry

The pure function the hook uses to calculate the size and position during a resize. The dragged edge moves while the opposite edge stays put. With alt or shift, the panel resizes symmetrically around its centre; shift on a side edge also resizes the other axis by the same amount.

PropTypeDefaultDescription
edge*
ResizeEdge
—

The edge or corner being dragged.

start*
PanelGeometry & { x: number; y: number }
—

Geometry and pointer position when the gesture started.

dx*
number
—

Horizontal pointer movement since the start, in pixels.

dy*
number
—

Vertical pointer movement since the start, in pixels.

alt*
boolean
—

Resize symmetrically around the centre.

shift*
boolean
—

Resize symmetrically; on a side edge, also resize the other axis.

viewport*
{ w: number; h: number }
—

Viewport size in pixels.

bounds*
DraggableResizableBounds
—

Size limits and edge guard.

Accessibility#

  • Moving and resizing are pointer-only; the hook handles no keyboard input.
  • Resize handles have no role or label. Mark them aria-hidden, as in the examples.

Notes#

  • The saved geometry is clamped to the viewport on load and again when the window shrinks.
  • resetPanel() and the clamp on window resize update geometry, but not the motion values. A panel styled with the motion values does not move until the next gesture.
  • Keep getDefaults stable (useCallback or useMemo); resetPanel changes when it changes.
  • Gesture listeners are removed when the component unmounts.