BUILDING WITHOUT LIBRARIES
Creating drag-and-drop, resizing, snap alignment, and layout editors using vanilla DOM events.
Why Go Vanilla?
External drag-and-drop libraries are great for simple use cases. But when you need pixel-perfect control — nested containers, snap-to-grid, multi-select with group resize, z-index management — you need to own the code.
Drag & Drop from Scratch
The core loop: pointerdown captures the target and offset, pointermove updates position via transform (GPU-accelerated), pointerup commits the change. Track drag state in a ref to avoid React re-renders during drag.
function useDrag(elementRef: RefObject<HTMLElement>) {
const dragState = useRef({ active: false, startX: 0, startY: 0, offsetX: 0, offsetY: 0 });
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const onPointerDown = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
dragState.current = {
active: true,
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
};
el.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragState.current.active) return;
const x = e.clientX - dragState.current.offsetX;
const y = e.clientY - dragState.current.offsetY;
// Use transform for GPU-accelerated movement
el.style.transform = `translate(${x}px, ${y}px)`;
};
const onPointerUp = () => {
dragState.current.active = false;
};
el.addEventListener('pointerdown', onPointerDown);
el.addEventListener('pointermove', onPointerMove);
el.addEventListener('pointerup', onPointerUp);
return () => {
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', onPointerUp);
};
}, [elementRef]);
}Resizing with Handles
Place 8 resize handles around the element. Each handle has a cursor style and resize direction. On drag, calculate the delta and apply it to width/height while respecting minimum dimensions.
type Direction = { x: -1 | 0 | 1; y: -1 | 0 | 1 };
const HANDLES: { position: string; cursor: string; dir: Direction }[] = [
{ position: 'top-left', cursor: 'nwse-resize', dir: { x: -1, y: -1 } },
{ position: 'top-right', cursor: 'nesw-resize', dir: { x: 1, y: -1 } },
{ position: 'bottom-left', cursor: 'nesw-resize', dir: { x: -1, y: 1 } },
{ position: 'bottom-right', cursor: 'nwse-resize', dir: { x: 1, y: 1 } },
];
function onResizeMove(e: PointerEvent, startBounds: DOMRect, dir: Direction) {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
const newWidth = Math.max(MIN_SIZE, startBounds.width + deltaX * dir.x);
const newHeight = Math.max(MIN_SIZE, startBounds.height + deltaY * dir.y);
// Shift held → maintain aspect ratio
if (e.shiftKey) {
const ratio = startBounds.width / startBounds.height;
newHeight = newWidth / ratio;
}
element.style.width = newWidth + 'px';
element.style.height = newHeight + 'px';
}Snap Alignment & Guides
Compare the dragged element's edges and center against all other elements. When within a threshold (5px), snap to that position and render a guide line.
function findSnapPoints(dragged: DOMRect, others: DOMRect[], threshold = 5) {
const snaps: { axis: 'x' | 'y'; value: number; guide: number }[] = [];
for (const other of others) {
// Horizontal snaps (left edge, center, right edge)
const hChecks = [
{ dragged: dragged.left, target: other.left },
{ dragged: dragged.left, target: other.right },
{ dragged: dragged.right, target: other.left },
{ dragged: dragged.right, target: other.right },
{ dragged: dragged.left + dragged.width / 2,
target: other.left + other.width / 2 },
];
for (const check of hChecks) {
if (Math.abs(check.dragged - check.target) < threshold) {
snaps.push({ axis: 'x', value: check.target, guide: check.target });
}
}
}
return snaps;
}