Rotation in a web based design canvas
Adding rotation to a design canvas looked like a one-line transform — until collision detection and resizing stopped agreeing with what was on screen.
A couple of months ago at work, I had the opportunity to implement rotation support in our web-based design canvas tool.
When we first scoped out the project, it seemed like a pretty simple feature to add: “Just add a CSS transform style with the rotation in degrees — done!” ...or so we thought!
It was sized on that assumption but we realised that this didn't hold. This post goes through why it looked easy, the two ways we could have built it, and why we took the more “expensive” approach.
Why it looked easy
We quickly realised that rotating an element visually really is a one-liner. You set a CSS transform and the browser does the work — text, buttons, images, etc.
What we had missed is that most of the work in a design canvas is interaction, not rendering. The moment an element can be rotated, everything the canvas does on the user's behalf — keeping elements inside their container, detecting overlaps, resizing from an edge handle — is operating on a shape that is no longer where the data says it is.
The specific thing that made this hard, and the thing the original scoping missed, was the distinction between global space and local space.
Global space is the canvas, and its axes never move: x increases to the right, y increases downwards, always.
Local space belongs to the element and turns with it. Its axes are pinned to the element itself — y runs from its north edge down to its south edge, x from its west edge across to its east — so within local space the element is always upright, no matter what it looks like on screen. Rotation does not exist inside local space at all; the rotation value only describes how far the two coordinate systems have been turned relative to each other. The closest analogy I can think of is a ship's compass: it stays level while the ship rolls with the waves, so the movement is happening only to the ship and the reading is unaffected. Whatever you measure in local space stays the same, however the element is turned.
Before rotation, those two coordinate systems were identical, so nobody had ever had to think about the difference — our code did not distinguish between them because it never needed to.
Rotation makes them diverge, and every piece of logic that assumed they were the same has to be found and corrected.
Setting the scene
Rotation was something we introduced to our elements much later down the line in the product, and the design decisions we made prior to its introduction unfortunately meant that supporting it was not going to be a seamless affair.
In our canvas, we use a Cartesian coordinate system where the origin point, [0,0], is located at
the top left. The data model for our positioned elements contains a position field which stores a
top-left coordinate value relative to its container, and then a dimensions property containing the
width and height.
interface CanvasElement {
position: {
top: number; // essentially the "y"
left: number; // the "x"
};
dimensions: {
width: number;
height: number;
};
rotation: number; // rotation in degrees
}
With this data model and elements always being upright, we could easily detect collisions because each element effectively formed an axis-aligned bounding box (AABB).
For example, to check whether an element was within the x-bounds of its container, we would take the element's left position as a starting point, add its width to find the end point, and then ensure that:
- The left position is greater than or equal to 0, and;
- The summed value
position.left + dimensions.widthdoes not exceed the container's width.
This worked perfectly... until rotation knocked on the door!
A 90° clockwise rotated element would mean that the top-left point now is positioned visually in the top-right corner. Our collision detection logic at the time, which relied on fixed orientation, broke as the top-left point is no longer aligned to the left side of the container.
Phantom collisions
Container bounds were not the only thing the upright boxes got wrong with rotation factored in. We couldn't keep the same collision algorithm we were using for AABB's because the canvas would flag a collision for two rotated elements that were in close proximity like below:
Two elements rotated on opposing diagonals can have overlapping bounding boxes while the elements themselves are nowhere near each other. The canvas reports a collision; the person looking at the screen sees a clear gap. These are known as phantom collisions, which is exactly what they feel like.
Two ways to build this
When I wrote the research document for this, I put up two proposals. I want to lay both out, because the one we did not take was genuinely viable and considerably cheaper.
Proposal A — rotation-aware code patches
Keep everything as it is, and correct for rotation at each point of use.
Under this approach the element's stored position never changes, and the selection highlight — the box with the resize handles on it — stays unrotated. Its top edge is always north, its right edge is always east, regardless of how the element inside it is turned. You simply grow the highlight's dimensions so the rotated element still fits inside it.
Then, feature by feature, you patch: convert the cursor deltas into the element's local space before resizing, work out which edge is really being dragged, compute the true corner positions before running a collision check.
For: it leverages the logic that already exists, needs much less refactoring, ships faster, and it would have worked fine for what we actually needed at the time — rotating one element at a time from the properties panel.
Against: every feature needs its own patch. Rotation stays a visual style that the system does not really acknowledge, so each new canvas feature has to remember to compensate for it. Manual rotation by dragging, or rotating several elements at once, would each mean another round of patches. Also, we would hit into the phantom collision problem we just mentioned.
Proposal B — treat rotation as a first-class property
Give the builder its own understanding of where a rotated element actually is. The stored data still does not change — position and dimensions remain exactly as they were — but the canvas derives rotation-aware geometry from them and works with that instead.
For: rotation becomes part of the model the canvas reasons about, so behaviour lines up with what people expect from design tools, and future features get it for free.
Against: more work upfront, and slower to ship.
Why B
Proposal A was the sensible short-term call but I argued against it, because I thought we would pay for it later — line snapping and manual rotation were both plausible next steps, and each one would have meant another patch.
But the argument that actually convinced me arrived while writing the two proposals out. Proposal A requires nearly all of the same geometry as Proposal B. You still need rotated corners for collision. You still need to convert deltas into local space for resizing. You still need a true bounding box. The maths does not go away — it just gets scattered across the features that need it, sitting on top of a model that does not acknowledge rotation exists.
So the choice was never “cheap maths versus expensive maths”. It was “the same maths, in one place that owns it, or the same maths, spread across every feature that happens to need it”. Framing it in that way, made the choice a lot simpler.
Why not just change the data model?
There is a third option I considered and rejected: why not move the stored origin from the top-left corner to the centre of the element?
When you rotate a shape, the centre is the one point that does not move — a 90° rotation changes every corner coordinate but leaves the centre exactly where it was. That makes the centre the natural origin for a rotation-aware system, and storing it would save recomputing it on every calculation.
Two things ruled it out.
The blast radius went well past the canvas. The stored position is consumed by more than the builder — the renderer that puts campaigns onto client sites reads it too. Changing its meaning is a breaking change to a contract shared by several systems, in service of a feature that lives in one of them.
CSS positions from the top-left. Even with centre coordinates stored, every render would have to convert them back to top-left values. We would have taken on a migration and then paid a conversion tax forever.
My conclusion here was that this should have been a decision made when the initial data model was fleshed out. It's definitely something that I'll be mindful of if I ever get the opportunity to build a canvas tool from scratch: make it rotation aware from the start.
So the data model stays as it is, and the rotation-aware view of it gets derived on demand.
The geometry layer
The goal was a small utility that takes an element and returns everything the canvas needs in order to reason about it once rotation is in play:
export function getElementGeometryLayer({
position,
dimensions,
rotation,
}: CanvasElement): ElementGeometryLayer {
const center = getElementCenter(position, dimensions);
const corners = getElementCorners(position, dimensions, rotation);
const edges = getElementEdges(position, dimensions, rotation);
const aabb = getAxisAlignedBoundingBox(position, dimensions, rotation);
return {
obb: { center, corners, edges },
aabb,
};
}
It returns two bounding boxes, and the distinction matters.
The axis-aligned bounding box (AABB) is the upright rectangle that fully contains the element. It ignores rotation. It is effectively what we had before, and it is cheap — four numbers, no trigonometry. Note that it does not match the element's shape once rotated; it is the smallest upright box the rotated element fits inside.
We kept it around for two reasons. It gives us a cheap first pass for collision, ruling out pairs that are obviously nowhere near each other before anything exact runs. It also covers multi-selection: when you select several elements, the highlight becomes a single upright box containing all of them, which is just the union of their AABBs, and a group resize works from that box.
The oriented bounding box (OBB) describes the element's actual geometry, rotated to match its orientation, and always matching its real dimensions. It contains:
center— the [x, y] coordinate of the element's centre pointcorners— four corner coordinates, in the order top-left, top-right, bottom-right, bottom-leftedges— four edge definitions (north, east, south, west), each with start and end points, the distance between them, and their midpoint
So we hang on to both: the AABB for the cheap checks, the OBB when the answer has to be exact.
Deriving all of this on demand rather than storing it sounds expensive, and mostly is not — it is a handful of trigonometric operations per element, and it is memoised against the element itself, so it recomputes when an element actually changes rather than on every render.
Integration
With the geometry layer defined, the final step was integrating it into the existing logic for moving, resizing and collision detection. Rather than rewriting everything, we refactored those calculations to consume the geometry layer instead of working directly with raw position and dimension data.
Moving elements
When moving elements, we check they remain within the bounds of their container. Previously those calculations assumed upright elements. Now they take the corner values from the OBB and validate against those instead.
Resizing rotated elements
This is where the global-versus-local distinction bites hardest.
Resizing tracks the change in cursor position between two points — the delta — and applies it to a
dimension based on which edge is being dragged. The original code assumed the two coordinate systems
were the same: deltaX only ever modified left and width, deltaY only ever modified top and
height.
Now consider an element rotated 90° clockwise. Its north edge appears on the right-hand side. Drag that edge to the right and the cursor is moving along the global x-axis — but in the element's own terms you are dragging its north edge, which should change its top and height.
The fix is to rotate the movement vector itself, converting the delta out of global space and into the element's local space before deciding what to do with it:
export function vectorRotate(vector: Vector, rotation: number): Vector {
const radians = (rotation * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
return {
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
};
}
Once the delta is in local space, the existing edge-direction logic works unchanged — the north handle always modifies the local y axis, wherever it happens to appear on screen. The geometry layer's centre point does the rest, offsetting the position correctly as the dimensions change.
Collision detection
The phantom collisions from earlier ruled out the upright boxes, so this one needed a new algorithm rather than a refactor. We moved to the Separating Axis Theorem (SAT), which works on the real rotated shapes. The idea is simple enough: two convex shapes do not overlap if there is any axis along which their projections do not overlap. For rectangles, the axes worth testing are the ones perpendicular to their edges.
export function checkIfPolygonsIntersect(polygonA: Vector[], polygonB: Vector[]): boolean {
for (const polygon of [polygonA, polygonB]) {
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
// The axis perpendicular to this edge
const axis = { x: -(b.y - a.y), y: b.x - a.x };
const [minA, maxA] = projectOnto(polygonA, axis);
const [minB, maxB] = projectOnto(polygonB, axis);
// A gap on any axis means there is no intersection
if (maxA < minB || maxB < minA) {
return false;
}
}
}
return true;
}
The corners come straight from each element's OBB. Note that the axes are never normalised — we only care whether the projections overlap, not by how much, so the square root would be wasted work.
This is where the cheap first pass from earlier comes in: run the upright check to throw out the pairs that are obviously far apart, then run SAT on whatever is left.
Closing statement
Through this additional geometry layer, we introduced full rotation support without altering the core data model, and no downstream system had to change.
Most of the time went into building the layer itself. After that, moving, resizing and collision were each a small refactor to consume it, and rotation shipped quickly.
The one thing I would change is that we have no way to observe how any of this performs. Performance never came up as a problem, and we never measured it — but there is no canvas performance monitoring either, so if it did degrade we would hear it from someone complaining rather than see it on a graph. That is what I would want in place before optimising any of it.
Read more writing →