DraggableResizableModal

A floating panel that can be moved and resized on desktop and falls back to a full-screen Modal on mobile.

DraggableResizableModal is a client component. From the sm breakpoint (48rem) up, it renders a free-floating panel, portalled to document.body, that users can drag by a handle and resize from any edge or corner. Its size and position are saved in localStorage under storageKey. Below sm, it renders a full-screen Modal instead.

It reads the viewport with useBreakpoint, so it must render inside a BreakpointProvider. The mobile fallback uses Modal, which needs a Next.js App Router tree.

Open file preview

Import#

typescript
import {
  DraggableResizableModal,
  ModalDragHandle,
  useDraggableModalDrag,
  GOLDEN_RATIO,
  SPLIT_DOCK_FRACTION,
} from "@reactberry/system/blocks";
import type { DraggableResizableModalProps } from "@reactberry/system/blocks";

Usage#

typescript
import { useState } from "react";
import { BreakpointProvider } from "@reactberry/system";
import { DraggableResizableModal, ModalDragHandle, useModalClose } from "@reactberry/system/blocks";
import { Box, Button, Text } from "@reactberry/system/elements";

function CloseButton() {
  const close = useModalClose();
  return <Button variant="ghost" onClick={() => close?.()}>Close</Button>;
}

export default function FilePreview() {
  const [open, setOpen] = useState(false);

  return (
    <BreakpointProvider>
      <Button variant="primary" onClick={() => setOpen(true)}>Preview</Button>
      {open && (
        <DraggableResizableModal storageKey="file-preview" onClose={() => setOpen(false)}>
          <ModalDragHandle p="s" display="flex" justifyContent="space-between">
            <Text fontWeight="600">report.pdf</Text>
            <CloseButton />
          </ModalDragHandle>
          <Box p="m" flex="1" overflow="auto">…</Box>
        </DraggableResizableModal>
      )}
    </BreakpointProvider>
  );
}

Examples#

Docked to the right#

Use dock="right" with overlay={false} for a side panel that leaves the page beside it usable.

Open side panel

API#

DraggableResizableModal#

PropTypeDefaultDescription
children*
ReactNode
—

Panel content. On desktop it is wrapped in a full-size flex column.

storageKey*
string
—

localStorage key for the saved size and position. Use a different key for each surface.

onClose
() => void
—

Called on Escape, on a backdrop click, or from useModalClose(). With animated, it is called after the exit animation.

defaultWidth
number
900

First-open width in pixels, used when nothing is saved and dock="center".

defaultHeight
number
640

First-open height in pixels, used when nothing is saved and dock="center".

dock
"center" | "right"
"center"

First-open position. "right" docks the panel to the right edge at near full height.

dockFraction
number
SPLIT_DOCK_FRACTION

Share of the viewport width the panel takes when dock="right".

bounds
Partial<DraggableResizableBounds>
—

Overrides minWidth (320), minHeight (240), maxWidthVw (0.98), maxHeightVh (0.98), and guard (8, the minimum gap in pixels to the viewport edge).

overlay
boolean
true

Set false to render no backdrop at all, leaving the rest of the page interactive.

backdrop
boolean
true

Tints the backdrop. When false, the backdrop is transparent but still catches clicks.

backdropProps
Record<string, any>
—

Props spread onto the backdrop.

closeOnOverlayClick
boolean
true

Close when the backdrop is clicked.

animated
boolean
true

Scale-and-fade spring on open and close.

maxWidth
any
—

Forwarded to the mobile Modal only; ignored on desktop.

maxHeight
any
—

Forwarded to the mobile Modal only; ignored on desktop.

Any other prop (for example skin, shape, border, boxShadow, $shadow) is spread onto the desktop panel and, on mobile, onto the Modal. Desktop defaults are skin="panel", shape="rounded", and $shadow="medium".

ModalDragHandle#

A Box that starts a move on pointer-down and shows a grab cursor (grabbing while moving). It accepts all Box props. On mobile it renders as a plain Box.

useDraggableModalDrag#

Returns the move handler for building your own drag surface.

PropTypeDefaultDescription
onStartMove
(e: React.PointerEvent) => void
—

Pointer-down handler that starts a move. A no-op outside the desktop panel.

isMoving
boolean
—

true while the panel is being moved. Always false outside the desktop panel.

Constants#

PropTypeDefaultDescription
GOLDEN_RATIO
number
1.6

Ratio for a side-by-side layout.

SPLIT_DOCK_FRACTION
number
1 / (1 + GOLDEN_RATIO)

About 0.385. The smaller share of a GOLDEN_RATIO : 1 split, used as the default dockFraction.

useModalClose() (from Modal) returns the close function inside both the desktop panel and the mobile fallback. For a custom floating surface, use the underlying useDraggableResizable hook.

Keyboard#

KeyAction
Escape

Closes the desktop panel (plays the exit animation when `animated`).

Accessibility#

  • The desktop panel is role="dialog" with aria-modal="true". Focus is not trapped.
  • Resize handles are role="presentation" with aria-hidden="true". Moving and resizing have no keyboard alternative.

Notes#

  • Only the left mouse button starts a move. Holding Alt or Shift while resizing grows or shrinks the panel symmetrically around its centre; Shift on a side edge also resizes the other axis.
  • The saved geometry is clamped to the current viewport on load and when the window shrinks. dock, dockFraction, and the default size only apply when nothing is saved for storageKey.
  • The panel sits at zIndex 100002; the backdrop at 100001.
  • Pointer, mouse, touch, click, and key events are stopped at the panel so they do not reach draggable ancestors in the React tree.
  • With animated, closing hides the panel and calls onClose after the exit animation. Unmount the component in onClose; if it stays mounted, the panel stays hidden.
  • The Escape listener is attached to window, so Escape closes the panel even when focus is elsewhere on the page.
  • Crossing the sm breakpoint while open swaps between the desktop panel and the mobile Modal, which remounts the children.