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.
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.
| Space | Unit | Origin | Where you meet it |
|---|---|---|---|
| Client (viewport) | CSS pixels | Top-left of the browser window | clientX and clientY on a pointer event[1] |
| Canvas | Canvas pixels | Top-left of the canvas element | Where drawing happens |
| Project | Project units | Project's own origin | Where layers are stored |
| Layer-local | Project units | Centre of one layer | Where 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.
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.
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.
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.
| Decision | What we do | Trade-off |
|---|---|---|
| Geometry source | Gizmo reads displayed placements only | Handles may briefly show the last drawn position |
| Off-screen handles | Pull them to the visible edge, leave on-screen ones alone | A large clip's corner sits on the edge, not at its true corner |
| Drag as a session | Commit once, with one undo entry | Undo steps over a whole drag, not each pointer move |
| Frame limits | Keep the centre inside the frame | A 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.
Keep reading
Compositing video layers with Canvas and WebGL: transforms, blends and effects
Composite video layers in the browser: Canvas 2D versus WebGL, transform matrices, blend modes, shader effects and drawing each frame in the right order.
Keyframe animation in a web editor: interpolation, easing curves and motion tracking
Implement keyframe animation for a web editor: storing keyframes, interpolating values, cubic Bézier easing, and pinning graphics to tracked motion.
More in Browser video editor
Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback
Export an MP4 from a web video editor: render frames, encode with the WebCodecs VideoEncoder, mux audio and video, and know when to render on a server.
Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
Build a playback loop for a web video editor: one master clock, requestAnimationFrame pacing, dropped-frame handling and keeping audio and video in sync.
WebCodecs tutorial: decoding video frames in the browser without a video tag
Decode video frame by frame with the WebCodecs VideoDecoder: demux samples, configure the decoder, seek to keyframes and release VideoFrame memory.