Skip to main content

Hit test → drag

Drag, resize and rotate handles for a canvas editor: building the transform gizmo

Build drag, resize and rotate handles for a canvas editor: hit testing, pointer capture, screen-to-canvas coordinates, aspect locks and rotated bounds.

14 min readEngineering

Part 10 of 15Build a browser video editor

Hit test → dragazelify
Engineering

Drag, resize and rotate handles for a canvas editor: building the transform gizmo

Key takeaways

  • Convert every pointer position from screen pixels into canvas units first; most handle bugs are a coordinate space mixed up somewhere.
  • Resize from the opposite corner as the fixed anchor, and do the maths in the layer's own rotated frame, not in screen axes.
  • Pointer capture keeps a drag alive when the cursor leaves the handle, and one history entry per drag keeps undo sensible.

A transform gizmo is the set of handles drawn over a selected layer so that a person can move, resize or rotate it directly on the canvas. Building resize and rotate handles for a canvas in JavaScript sounds like a drawing problem, but most of the work is answering three questions in order: where the pointer is, which handle it is over, and how the layer should change while the pointer moves. Get the first one wrong and every later step is wrong too.

This article builds that machinery from the bottom up. We start with the coordinate spaces a pointer passes through, then hit testing, then pointer capture, which keeps a drag alive when the cursor leaves the handle. After that we write the resize and rotate maths in the layer's own frame, and finish with the design choices that make undo and accessibility work. The Azelify editor, a browser-native video editor we build, uses the same ideas, and we describe its trade-offs near the end.

You need basic JavaScript and a rough picture of a rectangle on a grid. Trigonometry appears in one place, and it is explained where it is used.

What is a transform gizmo?#

A gizmo, in 3D tools and in editors, is an on-screen control that represents a transform. It is not part of the picture. It sits above the picture and gives the person something to grab. A transform gizmo for a two-dimensional layer has four parts: a body that moves the layer, corner handles that resize it, edge handles that resize one side, and a rotation zone just outside each corner that turns it.

Here is the layer we will use throughout. It is 400 by 225 units, its centre is at (960, 540), and it is turned by 20 degrees. The four corner handles and the body are drawn over the layer, and a press on any of them starts a different gesture. The geometry is the hard part, so most of the article is about getting it right.

Every number in this article is in one of several coordinate spaces, and mixing them is the most common cause of a handle that misses. The next section sorts them out.

Which coordinate space are you in?#

A coordinate space is a frame of reference: a set of axes with an origin and a unit. A pointer event reports a position in one space, your layer is stored in another, and the screen shows a third. Converting between them is a chain of small steps, and each step must be done once, in the right order.

SpaceUnitOriginWhere you meet it
Client (viewport)CSS pixelsTop-left of the browser windowclientX and clientY on a pointer event[1]
CanvasCanvas pixelsTop-left of the canvas elementWhere drawing happens
ProjectProject unitsProject's own originWhere layers are stored
Layer-localProject unitsCentre of one layerWhere its corners and rotation are computed

The first conversion goes from client pixels to canvas pixels. The canvas may be displayed at a different size from its internal resolution, so the scale between the two is the ratio of their sizes. The second conversion goes from canvas pixels to project units, which undoes whatever zoom and pan the preview applies.

// Turn a pointer event into project units.
// view holds the zoom and pan used to draw the project on the canvas.
function pointerToProject(event, canvas, view) {
  const rect = canvas.getBoundingClientRect();              // where the canvas sits
  const canvasX = (event.clientX - rect.left) * (canvas.width / rect.width);
  const canvasY = (event.clientY - rect.top) * (canvas.height / rect.height);
  return {
    x: (canvasX - view.panX) / view.zoom,
    y: (canvasY - view.panY) / view.zoom,
  };
}

Two things to remember. First, the pointer position is always read at the moment of the event, never from a cached value. Second, every later calculation uses project units, so a 2× zoom does not change the size of a handle or the outcome of a drag. If you find a handle that is offset by a constant amount, look for a missing conversion first.

Device pixels add one more space, and they cause a separate class of bugs, which we cover in a later section.

How does hit testing decide what a press means?#

Hit testing answers one question: given a pointer position, which part of the gizmo is under it? The answer decides what the press does. A simple approach is to test shapes in a fixed order and stop at the first match. The order matters, because some zones overlap.

Handles are small discs, so the test for a corner is a distance check. The rotation zone is a ring that starts where the corner disc ends, so the two never overlap. The body is the rotated rectangle, and testing it means rotating the pointer into the layer's own axes first.

const HANDLE_RADIUS = 10; // project units: the clickable disc around a corner
const ROTATE_REACH = 24;  // how far past the disc the rotation ring extends

// Is point p inside the layer? Undo the layer's rotation, then compare
// against the half-width and half-height in the layer's own axes.
function insideLayer(p, layer) {
  const dx = p.x - layer.cx;
  const dy = p.y - layer.cy;
  const cos = Math.cos(-layer.angle);
  const sin = Math.sin(-layer.angle);
  const lx = cos * dx - sin * dy; // p expressed in the layer's own axes
  const ly = sin * dx + cos * dy;
  return Math.abs(lx) <= layer.w / 2 && Math.abs(ly) <= layer.h / 2;
}

// corners is a list of [name, point] pairs in project units.
function classifyHit(p, layer, corners) {
  for (const [name, c] of corners) {
    const d = Math.hypot(p.x - c.x, p.y - c.y);
    if (d <= HANDLE_RADIUS) return { kind: "corner", name };
    if (d <= HANDLE_RADIUS + ROTATE_REACH) return { kind: "rotate", name };
  }
  // Edge handles are checked the same way, using the midpoints of the four sides.
  if (insideLayer(p, layer)) return { kind: "body" };
  return null; // empty space: the press does nothing
}

The order is deliberate, and it is chosen so that the first match is always the right one. The rotation ring sits outside the corner disc, so the two never overlap, and a press on the ring is never read as a resize. The corner is a small target, so it is tested before the body behind it. The body is the largest zone and comes last, because it would otherwise swallow every press near an edge. MDN's canvas transformations tutorial shows how a transform maps a layer's own coordinates onto the canvas, which is the relationship this maths relies on.[2] The figure turns that order into questions.

Fig. 01
A press is classified by the rotation ring, then the resize handles, then the bodyIllustrative decision tree for one press. First ask whether the pointer is on the rotation ring; if so the press rotates the layer. If not, ask whether it is on a resize handle; if so the press resizes with that handle. Otherwise a press inside the layer moves it.yesnoyesno
Pointer on the rotate ring?
Rotate around the centre
Pointer on a resize handle?
Resize with that handle
Move the whole layer
  1. Pointer on the rotate ring?
    1. yes → Rotate around the centre
    2. no → Pointer on a resize handle?
      1. yes → Resize with that handle
      2. no → Move the whole layer
A press is classified by the rotation ring, then the resize handles, then the body

The canvas API also offers isPointInPath, which tests whether a point lies inside a path you have built.[3] It is useful for odd shapes, but for a rotated rectangle the explicit maths above is shorter and easier to debug.

How does pointer capture keep a drag alive?#

A drag is a long gesture: the press happens on a handle, and the pointer may travel far outside it before the button is released. By default, pointer events go to whatever element is under the pointer at that moment, so a fast drag would leave the handle and stop receiving events. Pointer capture fixes that. It tells the browser to send all later events for one pointer to a chosen element until the button is released.[4]

The W3C specification describes the same rule and adds two details. Capture only takes effect while the pointer is in its active state, meaning a button is held down or a finger is touching. The browser releases capture implicitly after pointerup or pointercancel, so you do not need to release it yourself on a normal finish.[5]

let drag = null;

canvas.addEventListener("pointerdown", (event) => {
  const p = pointerToProject(event, canvas, view);
  const hit = classifyHit(p, layer, corners());
  if (!hit) return;                          // empty space: nothing to drag
  canvas.setPointerCapture(event.pointerId); // keep receiving moves off the canvas
  drag = beginDrag(hit, p);                  // snapshot the layer before it moves
});

canvas.addEventListener("pointermove", (event) => {
  if (drag) updateDrag(drag, pointerToProject(event, canvas, view));
});

canvas.addEventListener("pointerup", () => {
  if (drag) commitDrag(drag); // one history entry for the whole gesture
  drag = null;
});

canvas.addEventListener("pointercancel", () => {
  if (drag) cancelDrag(drag); // restore the snapshot
  drag = null;
});

Pointer events also unify mouse, pen and touch, which is one reason to use them rather than separate mouse and touch handlers.[6] The same code works on a laptop trackpad and on a tablet, although touch needs a larger handle, which is a sizing choice rather than an API change.

Step by step: resize from the opposite corner#

Resizing has one rule that makes the maths simple: the corner opposite the one being dragged must stay still. That corner is the anchor. The following six steps build a resize that behaves correctly at any angle.

Step 1: Snapshot the layer on pointer down#

When the drag begins, copy everything the gesture needs: the centre, size and angle of the layer, plus the press point. The rest of the drag reads from this snapshot, never from the live layer, because the live layer is changing under the pointer. Recomputing from changing values is how handles start to drift.

Step 2: Find the anchor in project units#

The anchor is the corner opposite the handle, measured from the centre along the layer's own axes. Because the layer is rotated, the anchor's position on the page must be rotated too. Each corner's sign in the layer's axes is -1 or +1, so the anchor of a south-east handle is the north-west corner.

Step 3: Express the pointer in the layer's frame#

Subtract the anchor from the pointer, then rotate the result by minus the layer's angle. The output is the pointer's position in axes that line up with the layer. This is the single most important step. If you skip it and measure in screen axes, a rotated layer will stretch in directions the pointer did not move.

Step 4: Set the size and find the new centre#

The dragged corner should sit exactly under the pointer, and the anchor should not move. In the layer's frame that gives a width and a height, and the new centre lies half a size away from the anchor along the layer's axes. The code below does all of this, including a minimum size, and includes the aspect lock that Shift applies.

const MIN_SIZE = 8; // project units: never collapse a layer to nothing

// start is the snapshot: { cx, cy, w, h, angle }.
// sx and sy are -1 or +1: which way the dragged corner lies from the centre.
function resizeFromCorner(start, sx, sy, p, shiftHeld) {
  const cos = Math.cos(start.angle);
  const sin = Math.sin(start.angle);

  // The anchor is the opposite corner, in project units.
  const ax = -sx * start.w / 2;
  const ay = -sy * start.h / 2;
  const anchor = {
    x: start.cx + cos * ax - sin * ay,
    y: start.cy + sin * ax + cos * ay,
  };

  // Pointer relative to the anchor, expressed in the layer's own axes.
  const dx = p.x - anchor.x;
  const dy = p.y - anchor.y;
  const localX = cos * dx + sin * dy;
  const localY = -sin * dx + cos * dy;

  let w = Math.max(MIN_SIZE, sx * localX);
  let h = Math.max(MIN_SIZE, sy * localY);
  if (shiftHeld) {                       // aspect lock: keep the start ratio
    const ratio = start.w / start.h;
    if (w / h > ratio) h = w / ratio;
    else w = h * ratio;
  }

  // The new centre is half a size from the anchor, along the layer's axes.
  const ox = sx * w / 2;
  const oy = sy * h / 2;
  return {
    w,
    h,
    cx: anchor.x + cos * ox - sin * oy,
    cy: anchor.y + sin * ox + cos * oy,
    angle: start.angle,
  };
}

The aspect lock compares the pointer's ratio with the start ratio and widens the shorter side. Many design tools use Shift for this same behaviour, so people who use them will expect it.

Step 5: Rotate with atan2#

Rotation is an angle, and the function that gives the angle of a point around a centre is Math.atan2.[7] It takes the y offset first and the x offset second, and returns radians from minus pi to pi. The rotation applied to a layer is the change in that angle since the press, so the layer turns by exactly the amount the pointer has swept around its centre.

// Rotate by the angle the pointer has swept around the layer's centre.
function rotateTo(drag, p, shiftHeld) {
  const { cx, cy } = drag.start;
  const angleNow = Math.atan2(p.y - cy, p.x - cx);
  const angleDown = Math.atan2(drag.downPoint.y - cy, drag.downPoint.x - cx);

  // Wrap the sweep into -pi to pi, so crossing the 180 degree line
  // does not make the layer jump a full turn.
  const raw = angleNow - angleDown;
  const sweep = Math.atan2(Math.sin(raw), Math.cos(raw));

  let angle = drag.start.angle + sweep;
  if (shiftHeld) {                          // snap to 15 degree steps
    const step = Math.PI / 12;
    angle = Math.round(angle / step) * step;
  }
  return { ...drag.start, angle };
}

The wrap line is the kind of detail that only shows up when a person drags past the left side of the layer. A helper that normalises angles is cheap insurance, and a unit test with a sweep across the boundary catches the bug early.

Step 6: Commit one history entry for the drag#

A drag may produce hundreds of pointer moves. Each move should update the picture, but only the start and the end should reach the undo history. The rule is simple to state: the snapshot is the before, the final layer is the after, and both belong to one command. We come back to this in the design section.

Why do the handles drift on high-DPI screens?#

Device pixel ratio, abbreviated DPR, is the number of physical screen pixels that make up one CSS pixel.[8] A value of 1 is a classic display, and 2 is common on high-density screens. If you draw a canvas at its CSS size on a 2× screen, the browser stretches it, and the picture looks soft. The fix is to make the canvas bigger in pixels and draw in CSS units.

const dpr = window.devicePixelRatio || 1;

canvas.width = Math.round(cssWidth * dpr);   // real pixels for the screen
canvas.height = Math.round(cssHeight * dpr);
canvas.style.width = `${cssWidth}px`;        // but display at CSS size
canvas.style.height = `${cssHeight}px`;

// Draw in CSS units; the transform maps them onto the extra pixels.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

The handle bug appears when this is only half done. If the canvas is drawn at high resolution but the hit test still uses raw canvas pixels, the outline and the target disagree by a factor of two, and handles appear to float away from the layer. Keep one rule: the same transform that draws the outline must be used to convert pointer positions, so the picture and the target always agree.

The figure shows a practical check on colours. Handles must remain visible against both the picture and their own outline.

Fig. 02
Check each handle colour against the colour it sits onTwo illustrative pairs for handle styling: white on a blue handle fill, and near-black on a light grey outline. Each pair shows the foreground and background hex values and whether it is sized as normal or large text, so the contrast ratio can be checked before the handles ship.White on blue handle5.17:1 ✓Near-black on light grey17.32:1 ✓
  • White on blue handle
    5.17:1 · ✓
  • Near-black on light grey
    17.32:1 · ✓
Check each handle colour against the colour it sits on

What makes a handle drag feel right?#

A drag can be mathematically correct and still feel wrong. The frame it draws may lag behind the pointer, the handles may jump when a drag ends, or undo may revert one pixel at a time. These problems come from the state machine around the gesture, not from the maths.

It helps to model the drag as a session with explicit states. A session starts idle, becomes active when a press hits a handle, and moves to committing when the pointer is released. It returns to idle only when the picture on screen has caught up with the committed value. The diagram shows that cycle.

Fig. 03
A drag moves from idle to active, then waits for the picture before going idleIllustrative four-state loop for one drag. Idle means no gesture is running. Active means draft placements follow the pointer. Committing holds the released draft until a frame showing it is drawn. Done records one history entry, and the loop returns to idle for the next gesture.
Idleno drag running
Activedrafts follow pointer
Committinghold until drawn
Doneone history entry
  1. 01 → Idleno drag running
  2. 02 → Activedrafts follow pointer
  3. 03 → Committinghold until drawn
  4. 04 → Doneone history entry
A drag moves from idle to active, then waits for the picture before going idle
Illustrative, not data[w3c-pointer]

The committing state is the subtle one. Without it, the handles would return to the old position for a frame after release, because the document has changed but the picture has not yet been redrawn. Holding the released draft until a frame at or past the new value is shown closes that gap.

How the Azelify editor handles handles#

The Azelify editor draws its gizmo from what is on screen, not from the document. The gizmo reads the placements of the frame that the picture was last drawn from, and it never recomputes a box from the stored project. That choice has a clear benefit: the outline cannot run ahead of the picture during an edit, and it cannot fall behind a cached or recovery frame. Handles follow the picture, so they may briefly show the last drawn position after a fast change.

Hit testing uses the same displayed points as drawing. A press that lands on a handle is read from the same geometry that draws the handle, which removes the drift described above. Rotation is recognised in a small zone that extends past each corner, and each corner has a distinct cursor so that the person can see what a press will do.

Large clips need special handling. A clip bigger than the visible area could put its corner handles off screen. The editor measures the part of the project that is visible and pulls any handle whose centre lies outside it back to the edge. Handles that are already on screen are never moved, because even small changes make a corner look detached from its outline. A clip's centre is held inside the frame during a move, so at most half of it can hang off an edge, while its size stays free.

The editor treats a drag as a gesture session with the states described above. A drag that ends in a commit produces one history entry, and the session waits until the committed frame is on screen before it returns to idle. During a drag, the editor renders the layers below and above the moving clips once, then redraws only the lifted clips at their draft positions between those saved layers. It falls back to a full render for each move when that shortcut is not safe, such as during a transition or with a blend that reads what lies beneath.

DecisionWhat we doTrade-off
Geometry sourceGizmo reads displayed placements onlyHandles may briefly show the last drawn position
Off-screen handlesPull them to the visible edge, leave on-screen ones aloneA large clip's corner sits on the edge, not at its true corner
Drag as a sessionCommit once, with one undo entryUndo steps over a whole drag, not each pointer move
Frame limitsKeep the centre inside the frameA clip cannot be parked entirely off screen

Accessibility: make handles reachable without a pointer#

Pointer handles are only one way to operate a gizmo. The W3C guidance on keyboard operation says that all functionality should be operable through a keyboard, except where the function depends on the path of a movement. Drawing a freehand line depends on the path. Resizing a window or dragging an object to a location does not, because only the end point matters.[9]

For a gizmo this means that arrow keys should nudge the selected layer, and modifier keys should change the size in fixed steps. A keyboard user does not need to know the handle geometry. They need a predictable action for each key, and a visible focus indicator on the selected layer. The rule is the same one used for pointer input: when you add a new gesture, decide its keyboard equivalent at the same time.

Common mistakes#

Mixing coordinate spaces. A handle that is offset by a constant usually means a conversion was skipped. Convert once, at the boundary, and keep everything else in project units.

Resizing about the centre. Dragging a corner while the centre stays fixed makes the layer grow in both directions. Resize from the opposite corner, as the anchor rule requires.

Measuring in screen axes. A rotated layer must be resized in its own frame. Screen axes make the layer stretch in the wrong direction.

Forgetting pointer capture. Without it, a fast drag leaves the handle and the gesture stops. Capture on pointerdown and release on pointerup.

Writing one undo entry per move. A drag becomes hundreds of entries, and undo takes hundreds of steps to undo one gesture. Record the start and the end only.

Frequently asked questions#

How do I make resize and rotate handles on a canvas in JavaScript?#

Store the layer as a centre, a width, a height and an angle. On pointer down, test which handle the press is over, then capture the pointer and record a snapshot. On each move, compute the new size or angle from the snapshot, and redraw. Commit once when the pointer is released.

Why is my handle in the wrong place on a high-DPI screen?#

The canvas is probably drawn at its CSS size, or the hit test uses a different scale from the drawing. Set the canvas size in device pixels, apply the device pixel ratio as the transform, and convert pointer positions with the same scale.

How do I resize from the opposite corner?#

Find the anchor as the corner opposite the handle, in the layer's own axes. Express the pointer relative to the anchor, rotated by minus the angle, and use the result as the new size. Then place the centre half a size from the anchor along the layer's axes.

How do I rotate a layer to follow the mouse?#

Use Math.atan2 from the centre to the pointer, compare it with the angle at the press, and add the difference to the layer's starting angle. Wrap the difference into minus pi to pi, so the layer does not jump when the pointer crosses the left side.

What is pointer capture and when do I need it?#

Pointer capture sends every later event for one pointer to a chosen element until the button is released. You need it for any drag that might leave the handle, which is almost every drag. The browser releases it automatically on pointerup or pointercancel.

How should a canvas gizmo support keyboard users?#

Give every pointer gesture a keyboard equivalent. Arrow keys can nudge the layer, and modifier keys can change its size in steps. The W3C guidance says the keyboard equivalent must cover the same or comparable actions, except where the action depends on the path of a movement.

Where to go next#

If you want to see the transform numbers that these handles control, read how video layers are composited with Canvas and WebGL. It explains how the same matrix places a layer in the final picture.

For the motion side, read keyframe animation and easing in JavaScript. Handles set the start and end of a move, and keyframes decide how it changes over time.

Sources

  1. MDN Web Docs, “PointerEvent,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  2. MDN Web Docs, “Transformations,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  3. MDN Web Docs, “CanvasRenderingContext2D: isPointInPath() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  4. MDN Web Docs, “Element: setPointerCapture() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  5. W3C, “Pointer Events,” www.w3.org (opens in a new tab) (accessed 2026-10-13). ↩

  6. MDN Web Docs, “Pointer events,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  7. MDN Web Docs, “Math.atan2() - JavaScript,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  8. MDN Web Docs, “Window: devicePixelRatio property,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  9. W3C WAI, “Understanding Success Criterion 2.1.1: Keyboard,” www.w3.org (opens in a new tab) (accessed 2026-10-13). ↩

Build a browser video editor

In this series

  1. 01How to build a video editor in the browser: the complete architecture
  2. 02Designing the project file for a web video editor: schema, versions and autosave
  3. 03Undo and redo in a web editor: the command pattern, explained from zero
  4. 04How a video editor timeline works: tracks, clips, trims and overlaps
  5. 05Importing video files in the browser: probing, codecs, proxies and storage
  6. 06WebCodecs tutorial: decoding video frames in the browser without a video tag
  7. 07Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
  8. 08Compositing video layers with Canvas and WebGL: transforms, blends and effects
  9. 09Text and animated captions in a browser video editor: let CSS do the drawing
  10. 10Drag, resize and rotate handles for a canvas editor: building the transform gizmo
  11. 11Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
  12. 12Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback
  13. 13Keyframe animation in a web editor: interpolation, easing curves and motion tracking
  14. 14How to test a video renderer: golden images, oracles and property-based tests
  15. 15FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack

Move, scale and rotate on the canvas

Select any layer in the preview and shape it with the handles.

Open the studio

Keep reading

More in Browser video editor

All guides