Building draggable word tiles with Pointer Events

Under the hood · Published 2026-10-20

The magnetic poetry board is about 200 lines of plain JavaScript. Most of the interesting parts are about dragging things reliably across mouse, finger and stylus. This is how it works.

Why Pointer Events

For years, draggable interfaces needed two code paths: mouse events (mousedown, mousemove, mouseup) and touch events (touchstart, touchmove, touchend), each with its own quirks. Pointer Events, a W3C standard supported by all current major browsers, unify them: pointerdown, pointermove, pointerup and pointercancel fire for any pointing device, and each event carries a pointerType ("mouse", "touch" or "pen") and a pointerId to tell simultaneous pointers apart. You also no longer need the native HTML drag-and-drop API, which is designed for moving data between places, has poor touch support and gives little control over what the dragged element looks like.

The board

The board is a relatively positioned container; each tile is an absolutely positioned <button>. Buttons are a deliberate choice: they are focusable with Tab, announced by screen readers and can be activated with Enter or Space without extra code.

.board { position: relative; height: 560px; overflow: hidden; touch-action: none; user-select: none; }
.tile  { position: absolute; cursor: grab; }

Two CSS properties matter here. touch-action: none tells the browser not to treat touches on the board as scrolling or zooming gestures; without it, a finger drag on a phone scrolls the page and the browser fires pointercancel partway through the drag. user-select: none stops a fast mouse drag from selecting tile text. Apply touch-action only to the board, not the whole page, so that people can still scroll past it.

Dragging with pointer capture

function makeDraggable(tile, board) {
  let startX, startY, originX, originY, activeId = null, moved = false;

  tile.addEventListener('pointerdown', (e) => {
    activeId = e.pointerId;
    tile.setPointerCapture(e.pointerId);
    startX = e.clientX; startY = e.clientY;
    originX = tile.offsetLeft; originY = tile.offsetTop;
    moved = false;
  });

  tile.addEventListener('pointermove', (e) => {
    if (e.pointerId !== activeId) return;
    const dx = e.clientX - startX, dy = e.clientY - startY;
    if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
    place(tile, board, originX + dx, originY + dy);
  });

  const end = (e) => { if (e.pointerId === activeId) activeId = null; };
  tile.addEventListener('pointerup', end);
  tile.addEventListener('pointercancel', end);
}

function place(tile, board, x, y) {
  const maxX = board.clientWidth - tile.offsetWidth;
  const maxY = board.clientHeight - tile.offsetHeight;
  tile.style.left = Math.max(0, Math.min(maxX, x)) + 'px';
  tile.style.top  = Math.max(0, Math.min(maxY, y)) + 'px';
}

The key line is setPointerCapture. Without it, moving the pointer quickly takes it off the small tile, the tile stops receiving pointermove events and the drag "drops". With capture, all events for that pointer are delivered to the tile until the pointer is released, wherever it goes, and capture is released automatically on pointerup or pointercancel. Tracking pointerId means a second finger landing elsewhere does not hijack the drag.

Positions are computed from the difference between the current pointer position and where the drag started, added to the tile's original position. This avoids the "jump" you get if you set the tile's top-left corner to the pointer position. The place function clamps the tile inside the board so it can never be lost off an edge.

Finally, raise the tile being dragged above the others by incrementing a shared z-index counter on pointerdown, so the tile you are holding is never hidden behind another.

Telling a drag from a click

A button also fires a click after a short press and release, including at the end of a drag. The moved flag, set once the pointer travels more than three pixels, lets the click handler ignore clicks that ended a drag. The board uses a double-click (or Enter on a focused tile) to send a tile to the poem or back to the pile. Keyboard activation can be recognized because a click generated by Enter or Space has event.detail === 0, while a mouse click has a detail of 1 or more.

Reading tiles back into text

Turning a scattered set of tiles into lines of text is a small geometry problem. The board takes the tiles inside the poem strip, sorts them by vertical position, and groups them into rows: a tile joins the current row if its vertical centre is within about 60 percent of a tile height of the row's centre; otherwise it starts a new row. Each row is then sorted left to right and joined with spaces.

function rows(tiles) {
  const sorted = [...tiles].sort((a, b) => a.offsetTop - b.offsetTop);
  const out = [];
  for (const t of sorted) {
    const mid = t.offsetTop + t.offsetHeight / 2;
    const row = out[out.length - 1];
    if (row && Math.abs(mid - row.mid) < t.offsetHeight * 0.6) row.push(t);
    else { const r = [t]; r.mid = mid; out.push(r); }
  }
  return out.map(r => r.sort((a, b) => a.offsetLeft - b.offsetLeft)
                       .map(t => t.textContent).join(' '));
}

Using the first tile's centre for the row, rather than a running average, keeps the result stable when tiles are slightly tilted or staggered, which is how people actually arrange them. Suffix tiles such as ing are glued to the preceding word as a final step.

Sharing without a server

The share link puts the poem in the URL fragment: /magnetic/#poem=moon%20under%20salt. The fragment, everything after #, is never sent to the server in an HTTP request, so the poem stays between the people who exchange the link. On load, the script reads location.hash, decodes it with decodeURIComponent inside a try block (a malformed link should not break the page), limits its length, and lays the words out as tiles in the strip. history.replaceState updates the address bar without adding a history entry each time.

Two safety habits apply. Tiles are created with textContent, never innerHTML, so a crafted link cannot inject markup. And everything taken from the URL is length-limited, so a giant link cannot create thousands of elements.

Small things that matter

None of this requires a framework. If you build something similar, the MDN guide to pointer events covers multi-touch, pressure and pen tilt, which open the door to drawing and gesture interfaces built on exactly the same handful of events.