SVG

How to build a resizable box

15. December 2020 10 min reading time

In this article I'll show you how to build a resizable box. I'll go over the math of resizing first and will then show you how to turn it into code with javaScript. By the end you should be able to build something like this:

I wrote the example in Svelte but if you know Vue or React you should be able to read it. If you want, go check out the code.

The math

Let's start by establishing a coordinate system. If you forgot how coordinate systems work, here's a 6 minute catch up.

A common approach to resizing is to try to change the width or height of an element directly. This article shows such an approach and all the edge cases that arise from it.

By doing it in a mathematical way we avoid most of the edge cases and end up with code that's easier to read/debug. It's also easy to extend with features like resizing multiple elements at the same time or preserving the aspect ratio of a box.

The specific implementation for this doesn't matter. I use SVG (which has an actual coordinate system) but you can also turn your new coordinates back into width / height values after doing the math.

For our example, we can define our shape with 3 points. Each point has an x and a y coordinate. To scale our triangle we need to multiply each point by the same factor. So If we want a triangle twice the size we take the x and y of each point and multiply by 2. We call this number the "growth factor" (or growth rate).

  • growth factor 2 -> double the size
  • growth factor 0.5 -> half the size

The top point starts with the coordinates x = 1, y = 0. If we multiply both by 2 we get the new point x = 2, y = 0. Do that with every point and you scaled your element to twice the size!

Making it relative

Unfortunately, we have a problem. Our coordinates are defined in relation to the entire coordinate system. So when our shape is not at the origin (0 / 0) of the coordinate system, this happens:

Notice how the left side of the triangle also moves? We want to move the triangle in relation to itself, not in relation to the entire coordinate system

To do this we'll create a new point and we'll call this point the anchor.

point anchor

We'll place the anchor opposite to where our mouse cursor is. So the x coordinate is equal to the point of the triangle that's the furthest left and the y coordinate is equal to the point that's furthest on top. The red box around the triangle shows the space the triangle takes up. We call this the bounding box (or bbox for short).

If you're confused at this point, don't worry. Have another look at the ilustration and the demo and check how the anchor and the bbox behave.

We defined our anchor point, next all we need to do is to define all of our coordinates relative to this anchor point (and in this case relative to the top left corner of the shape's bbox).

The green arrow shows the absolute distance which is our current x coordinate. We can get the distance relative to our anchor like this:

const pointRelativeToAnchor = {
x: point.x - anchor.x,
y: point.y - anchor.y
}

Putting it all together

We've introduced all our variables. Now let's put it all together and build our resizing function.

What we're ultimately looking for are the coordinates of our point after our user resized the rect. We'll call this coordinate the "endPoint".

First let's start by getting all of our relative coordinates

// mouse coordinates at the start of the drag action
const relativeStartMouseX = startMouseX - anchor.x
const relativeStartMouseY = startMouseY - anchor.y
// mouse coordinates at the end of the drag action
const relativeEndMouseX = endMouseX - anchor.x
const relativeEndMouseY = endMouseY - anchor.y
// point coordinates at the start of the drag action
const startPointRelativeToAnchor = {
x: startPoint.x - anchor.x
y: startPoint.x - anchor.y
}

From the change in mouse coordinates we can get the growth factor our users want:

const growthFactor = {
x: relativeStartMouseX / relativeEndMouseX,
y: relativeStartMouseY / relativeEndMouseY,
}

To calculate our new relative coordinates we multiply the start point by the growth factor

const endPointRelativeToAnchor = {
x: startPointRelativeToAnchor.x * growthFactor.x,
y: startPointRelativeToAnchor.y * growthFactor.y,
}

then we can turn them back into absolute coordinates like this

const endPoint = {
x: endPointRelativeToAnchor.x + anchor.x,
y: endPointRelativeToAnchor.y * anchor.y,
}

If we want to scale it into any other direction we can use the same math, all we need to do is move our anchor somewhere else:

The interactions

There are 3 actions a user can take, mousedown, mousemove and mouseup.

mousedown

We're going to attach a mousedown even to every edge and corner of our bbox.

function handleMouseDown(e, direction){
// transform mouse coordinates into svg space
let {x,y} = getSvgCoordinates(e, svg)
// can have 2 directions at once (like ne)
let directions = direction.split("");
// move the anchor to the opposing edge(s) of bbox
directions.forEach(direction => {
if (/[n]/.test(direction)) anchor.y = bbox.bottom;
if (/[e]/.test(direction)) anchor.x = bbox.left;
if (/[s]/.test(direction)) anchor.y = bbox.top;
if (/[w]/.test(direction)) anchor.x = bbox.right;
});
// copy the start state
startState = { direction, shapePoints, x, y };
};

the direction is a string like "n", "w", "ne" etc. A direction of "ne" means the point clicked is the top right (north-east) corner. In that case we would move the anchor all the way to the edge and all the way to the bottom. That way it's going to end up at the bottom left edge - opposite to our clicked corner.

the startState is a copy of the mousecoordinates and the points of our triangle.

mousemove

the mousemove should event happen when the user moves his mouse anywhere in our SVG.

function handleMouseMove(e) {
if (!startState) return;
let mousePosition = relativeToAnchor(getSvgCoordinates(e, svg));
let startMousePosition = relativeToAnchor(startState);
// can grow individually on each axis
// to lock aspect ratio use the same growth rate for x and y
let xGrowthRate = mousePosition.x / startMousePosition.x;
let yGrowthRate = mousePosition.y / startMousePosition.y;
// recalculate every point
shapePoints = startState.shapePoints.map(({x,y}) => {
// move sideways
if (/[ew]/.test(startState.direction)) {
let startX = x - anchor.x;
let endX = startX * xGrowthRate;
x = endX + anchor.x;
}
// move up and down
if (/[ns]/.test(startState.direction)) {
y = (y - anchor.y) * yGrowthRate + anchor.y;
}
return { x, y };
});
}

mouseup

The mouseup event can also happen anywhere. When it is triggered all we do is reset the startState. That way the mousemove event will do nothing.

🎉🎉 You did it!

That's it! You now have all the information to build your own implementation. If you're unsure about what to do next have another look at the code for the demo. If you have any questions or feedback tweet at me.