frameable
v0.2.1
Published
Headless React hooks for dragging, resizing and rotating frames on a 2D surface
Maintainers
Readme
You own the geometry. Frameable owns the math.
Frameable turns pointer and keyboard input into changes to a plain Frame object that lives in your state. It never reads layout from the DOM and never writes styles, so it stays correct inside zoomed canvases, CSS transform, CSS zoom, nested rotated groups and virtualized lists.
It is the successor to the role react-moveable, react-rnd and @use-gesture/react play today, designed around the failure modes those libraries collected in their issue trackers.
npm i frameableQuick start
import { useState } from 'react';
import { Surface, useFrame, toStyle } from 'frameable';
function Box({ frame, onChange }) {
const f = useFrame({ frame, onChange, constraints: { minWidth: 40, minHeight: 40 } });
return (
<div
{...f.getDragProps()}
{...f.getKeyboardProps()}
style={{ ...toStyle(frame), ...f.getDragProps().style }}
>
<span {...f.getHandleProps('se')} className="handle" />
<span {...f.getRotateProps()} className="rotate" />
</div>
);
}
export function Canvas() {
const [frame, setFrame] = useState({ x: 80, y: 80, width: 240, height: 160, rotation: 0 });
return (
<Surface style={{ position: 'absolute', inset: 0 }}>
<Box frame={frame} onChange={setFrame} />
</Surface>
);
}The frame is yours. Put it in useState, Zustand, Jotai, Yjs or a database row. Frameable only calls onChange with the next value.
Why another one
Open issues on the libraries this replaces cluster into five problems. Each one is a consequence of the library reading and writing the DOM.
| Problem | Frameable |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Resize and rotate math drifts, subpixel rounding | Deltas are computed from the initial pointer, never accumulated |
| Breaks when a parent is zoomed or transformed | Surface converts screen pixels to surface units with an explicit matrix |
| Inputs and editable text inside the element stop working | Pointer downs on input, textarea, button, a, [contenteditable] are ignored by default |
| Snapping is a wall of boolean flags | A snapper is a function: (candidate, ctx) => { frame, guides } |
| No keyboard, no screen reader | Arrow keys, modifiers and ARIA attributes ship with getKeyboardProps() |
Zoomed canvases
Pass your zoom and pan to Surface and every interaction resolves in surface units. Or omit viewport and let Surface measure its own element, which also handles CSS zoom and transform: scale() on ancestors.
<Surface viewport={{ zoom, pan }} style={{ position: 'absolute', inset: 0 }}>
<div
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: '0 0',
}}
>
<Box frame={frame} onChange={setFrame} />
</div>
</Surface>API
useFrame(options)
| Option | Type | Description |
| --------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| frame | Frame | Controlled value: { x, y, width, height, rotation } in surface units |
| onChange | (frame) => void | Called on every animation frame during an interaction and once at the end |
| onTransaction | (t) => void | Full lifecycle: start, update, end, cancel with initial, frame, delta, modifiers, source |
| constraints | Constraints | minWidth, minHeight, maxWidth, maxHeight, aspectRatio, bounds, operations, rotationStep |
| snap | Snapper \| Snapper[] | Composable snapping, see below |
| disabled | boolean | Removes all listeners, keeps props stable |
| interactiveSelector | string \| false | Elements that should receive pointer events instead of starting a drag |
| label | string | Accessible name for the frame |
Returns { frame, isActive, transaction, guides, getDragProps, getHandleProps, getRotateProps, getKeyboardProps }. Spread the prop getters on your elements. Handles are named n, ne, e, se, s, sw, w, nw.
Modifiers
Same semantics as Figma, on by default.
Shiftwhile resizing keeps the aspect ratio. While rotating, snaps to 15°. While moving, locks to one axis.Altwhile resizing scales from the center.Escapecancels the interaction and restores the initial frame.
Keyboard
| Keys | Action |
| ---------------------- | --------------------------------- |
| Arrows | Move by 1 |
| Shift + Arrows | Move by 10 |
| Alt + Arrows | Resize from the bottom-right |
| Cmd/Ctrl + [ ] | Rotate by 1°, by 15° with Shift |
Snapping
import { snapToFrames, snapToGrid } from 'frameable';
useFrame({ frame, onChange, snap: [snapToGrid(8), snapToFrames(otherFrames)] });snapThreshold is in screen pixels, converted to surface units through the current zoom. Keyboard nudges are never snapped. snapToFrames(frames, { edges, centers }) aligns edges and centers to other frames and returns the guides to draw. A snap that would break minWidth, maxHeight, bounds or a locked aspect ratio is discarded. A custom snapper is a pure function. Return null to pass through.
const snapToBaseline: Snapper = (candidate, ctx) => {
if (ctx.kind !== 'move') return null;
const y = Math.round(candidate.y / 4) * 4;
return { frame: { ...candidate, y }, guides: [{ axis: 'y', position: y }] };
};useSelection(options)
Marquee and click selection over any set of frames. It reads frames through getFrame, never the DOM, so virtualized and off-screen items are selectable.
const surface = useRef<SurfaceHandle>(null);
const sel = useSelection({
items,
selected,
onChange: setSelected,
getFrame: item => item.frame,
mode: 'intersect',
surface,
});
<Surface ref={surface} {...sel.getSurfaceProps()}>
{items.map(item => (
<Box key={item.id} {...sel.getItemProps(item.id)} />
))}
{sel.marquee && <div style={toStyle(sel.marquee)} />}
</Surface>;| Option | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------- |
| mode | 'intersect' (default) or 'contain' |
| getId | Required unless items have a string id |
| surface, viewport | Resolve the marquee through the Surface zoom and pan. Pass the Surface ref, or viewport alone |
| onTransaction | select lifecycle (start, update, end, cancel) so history can coalesce a marquee into one step |
| interactiveSelector | Presses on matching elements never start a marquee. Same default as useFrame |
Shift + click toggles, Shift + drag adds, Alt + drag subtracts, Escape restores the previous selection. getSurfaceProps(userProps) and getItemProps(id, userProps) compose with your own handlers and style.
useGroup(options) and Transformer
useGroup turns N items into one transformable frame and applies every change back to them. It calls useFrame internally, so it returns the same prop getters (getDragProps, getHandleProps, getRotateProps, getKeyboardProps) plus frame, guides and isActive. Transformer is the one styled component, built on the same controller.
import 'frameable/transformer.css';
const group = useGroup({
items: selectedItems,
getFrame: item => item.frame,
onChange: updates => updateMany(updates),
snap: snapToFrames(otherFrames),
});
<Transformer {...group.getTransformerProps()} draggable={false} zoom={zoom} />;onChangereceives{ id, frame }[].onTransactionreceives the group transaction plusmembers, one{ id, initial, frame }per item, so undo and sync get per-member before and after values.group.frameisnullfor an empty selection. It keeps its rotation after a transaction for as long as the items stay where the group left them, so repeated rotation pivots around the same point. If the items change from outside, it falls back to an unrotated bounding box.- To drag the group from the members themselves, spread
group.getDragProps()on each selected member and render<Transformer draggable={false} />. Members stay clickable andShift+ click keeps working. constraintsapply to the group box, not to each member.- A
Framecannot represent shear, so a non-uniform scale is only exact for members aligned to the group axes in quarter turns.nonUniformScaledecides what happens otherwise.'lock'(default) keeps the aspect ratio of the group while any member is turned off the group axes, so every resize stays exact.'approximate'allows free resizing and fits each turned member to its stretched axis, keeping its area. When all members share one rotation, the group adopts it and resizes freely and exactly. Transformeralso works alone withframeandonChange, accepts everyuseFrameoption plushandles,rotate,draggable,zoom,classNameandonGuidesChange. Pass the viewportzoomso handles and outline keep their on-screen size. Style it with--frameable-colorand--frameable-handle-size.
Surface
Provides the coordinate system. Renders a div, accepts viewport={{ zoom, pan }} and any div props. Exposes getMatrix() through its ref so you can convert pointer positions yourself.
frameable-core
Everything above is built on pure functions with no React and no DOM: move, resize, rotate, applyBounds, runSnappers, groupBounds, applyToGroup, selectInMarquee, combineSelection, viewport matrices and style helpers. Use it to build bindings for other frameworks or to precompute layouts on the server.
Comparison
| | Frameable | react-moveable | react-rnd | @use-gesture/react | | --------------------------- | -------------------- | --------------------- | ------------ | --------------------- | | Maintained | yes | no release since 2024 | slow | no release since 2024 | | Controlled geometry | yes | no | partial | n/a | | Correct under zoomed parent | yes | no | no | manual | | Rotate | yes | yes | no | manual | | Snapping | function | flags | grid only | no | | Keyboard | yes | no | no | no | | Headless | yes | no | no | yes | | Size, min+gzip | 5.1 kB + 3.6 kB core | 106 kB | not measured | 8 kB |
Frameable is complementary to @dnd-kit/react and pragmatic-drag-and-drop. Use those when the question is "where in this list does this go". Use Frameable when the question is "what are the coordinates of this thing now".
Roadmap
- 0.1
useFrame,Surface,snapToGrid, keyboard. Replacesreact-rnd. - 0.2 (done)
useSelection,useGroup,snapToFrames, styledTransformer. Replacesreact-moveableandreact-selecto. - 0.3
usePinch, guide rendering helpers, Vue bindings. - 1.0 API freeze.
The full design, with the data behind these decisions, is in RFC 001.
License
MIT
