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.
Import#
import {
DraggableResizableModal,
ModalDragHandle,
useDraggableModalDrag,
GOLDEN_RATIO,
SPLIT_DOCK_FRACTION,
} from "@reactberry/system/blocks";
import type { DraggableResizableModalProps } from "@reactberry/system/blocks";
Usage#
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.
API#
DraggableResizableModal#
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | — | Panel content. On desktop it is wrapped in a full-size flex column. |
storageKey* | string | — |
|
onClose | () => void | — | Called on Escape, on a backdrop click, or from |
defaultWidth | number | 900 | First-open width in pixels, used when nothing is saved and |
defaultHeight | number | 640 | First-open height in pixels, used when nothing is saved and |
dock | "center" | "right" | "center" | First-open position. |
dockFraction | number | SPLIT_DOCK_FRACTION | Share of the viewport width the panel takes when |
bounds | Partial<DraggableResizableBounds> | — | Overrides |
overlay | boolean | true | Set |
backdrop | boolean | true | Tints the backdrop. When |
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 |
maxHeight | any | — | Forwarded to the mobile |
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.
| Prop | Type | Default | Description |
|---|---|---|---|
onStartMove | (e: React.PointerEvent) => void | — | Pointer-down handler that starts a move. A no-op outside the desktop panel. |
isMoving | boolean | — |
|
Constants#
| Prop | Type | Default | Description |
|---|---|---|---|
GOLDEN_RATIO | number | 1.6 | Ratio for a side-by-side layout. |
SPLIT_DOCK_FRACTION | number | 1 / (1 + GOLDEN_RATIO) | About |
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#
| Key | Action |
|---|---|
Escape | Closes the desktop panel (plays the exit animation when `animated`). |
Accessibility#
- The desktop panel is
role="dialog"witharia-modal="true". Focus is not trapped. - Resize handles are
role="presentation"witharia-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 forstorageKey. - The panel sits at
zIndex100002; the backdrop at100001. - 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 callsonCloseafter the exit animation. Unmount the component inonClose; 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
smbreakpoint while open swaps between the desktop panel and the mobileModal, which remounts the children.