SKIP TO CONTENT
HOME/LEARN/FRONTEND
DOMVANILLA JS

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.

tsx
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.

tsx
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.

tsx
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;
}