Glue Cross-Browser Responsive Irregular Images with Sticky Tape

I recently came across this Atlas of Makers by Vasilis van Gemert. Its fun and quirky appearance made me look under the hood and it was certainly worth it! What I discovered is that it was actually built making use of really cool features that so many articles and talks have been written about over the past few years, but somehow don't get used that much in the wild - the likes of CSS Grid, custom properties, blend modes, and even SVG.

SVG was used in order to create the irregular images that appear as if they were glued onto the page with the pieces of neon sticky tape. This article is going to explain how to recreate that in the simplest possible manner, without ever needing to step outside the browser. Let's get started!

The first thing we do is pick an image we start from, for example, this pawesome snow leopard:

Fluffy snow leopard walking through the snow, looking up at the camera with an inquisitive face.
The image we'll be using: a fluffy snow leopard.

The next thing we do is get a rough polygon we can fit the cat in. For this, we use Bennett Feely's Clippy. We're not actually going to use CSS clip-path since it's not cross-browser yet (but if you want it to be, please vote for it - no sign in required), it's just to get the numbers for the polygon points really fast without needing to use an actual image editor with a ton of buttons and options that make you give up before even starting.

We set the custom URL for our image and set custom dimensions. Clippy limits these dimensions based on viewport size, but for us, in this case, the actual dimensions of the image don't really matter (especially since the output is only going to be % values anyway), only the aspect ratio, which is 2:3 in the case of our cat picture.

Clippy screenshot showing from where to set custom dimensions and URL. The custom dimensions need to satisfy a 2:3 ratio so we pick 540 = 2*270 for the width and 810 = 3*270 for the height.
Clippy: setting custom dimensions and URL.

We turn on the "Show outside clip-path" option on to make it easier to see what we'll be doing.

Animated gif. Illustrates turning on the 'Show outside clip-path' option. This is going to be useful later, while picking the vertices of the polygon that roughly clips the image around the cat, so that we can see outside the partial polygon we have before we get all the points.
Clippy: turning on the "Show outside clip-path" option.

We then choose to use a custom polygon for our clipping path, we select all the points, we close the path and then maybe tweak some of their positions.

Clippy: selecting the points of a custom polygon that very roughly approximates the shape of the cat.

This has generated the CSS clip-path code for us. We copy just the list of points (as % values), bring up the console and paste this list of points as a JavaScript string:

let coords = '69% 89%, 84% 89%, 91% 54%, 79% 22%, 56% 14%, 45% 16%, 28% 0, 8% 0, 8% 10%, 33% 33%, 33% 70%, 47% 100%, 73% 100%';

We get rid of the % characters and split the string:

coords = coords.replace(/%/g, '').split(', ').map(c => c.split(' '));

We then set the dimensions for our image:

let dims = [736, 1103];

After that, we scale the coordinates we have to the dimensions of our image. We also round the values we get because we're sure not to need decimals for a rough polygonal approximation of the cat in an image that big.

coords = coords.map(c => c.map((c, i) => Math.round(.01*dims[i]*c)));

Finally, we bring this to a form we can copy from dev tools:

`[${coords.map(c => `[${c.join(', ')}]`).join(', ')}]`;
Screenshot of the steps described above in the dev tools console.
Screenshot of the steps above in the dev tools console.

Now we move on to generating our SVG with Pug. Here's where we use the array of coordinates we got at the previous step:

- var coords = [[508, 982], [618, 982], [670, 596], [581, 243], [412, 154], [331, 176], [206, 0], [59, 0], [59, 110], [243, 364], [243, 772], [346, 1103], [537, 1103]];
- var w = 736, h = 1103;

svg(viewBox=[0, 0, w, h].join(' '))
  clipPath#cp
    polygon(points=coords.join(' '))
  image(xlink:href='snow_derpard.jpg' 
        width=w height=h 
        clip-path='url(#cp)')

This gives us the irregular shaped image we've been after:

See the Pen by thebabydino (@thebabydino) on CodePen.

Now let's move on to the pieces of sticky tape. In order to generate them, we use the same array of coordinates. Before doing anything else at this step, we read its length so that we can loop through it:

-// same as before
- var n = coords.length;

svg(viewBox=[0, 0, w, h].join(' '))
  -// same as before
  - for(var i = 0; i < n; i++) {
  
  - }

Next, within this loop, we have a random test to decide whether we have a strip of sticky tape from the current point to the next point:

- for(var i = 0; i < n; i++) {
  - if(Math.random() > .5) {
    path(d=`M${coords[i]} ${coords[(i + 1)%n]}`)
  - }
- }

At first sight, this doesn't appear to do anything.

However, this is because the default stroke is none. Making this stroke visible (by setting it to an hsl() value with a randomly generated hue) and thicker reveals our sticky tape:

stroke: hsl(random(360), 90%, 60%);
stroke-width: 5%;
mix-blend-mode: multiply

We've also set mix-blend-mode: multiply on it so that overlap becomes a bit more obvious.

See the Pen by thebabydino (@thebabydino) on CodePen.

Looks pretty good, but we still have a few problems here.

The first and most obvious one being that this isn't cross-browser. mix-blend-mode doesn't work in Edge (if you want it, don't forget to vote for it). The way we can get a close enough effect is by making the stroke semitransparent just for Edge.

My initial idea here was to do this in a way that's only supported in Edge for now: using a calc() value whose result isn't an integer for the RGB components. The problem is that we have an hsl() value, not an rgb() one. But since we're using Sass, we can extract the RGB components:

$c: hsl(random(360), 90%, 60%);
stroke: $c;
stroke: rgba(calc(#{red($c)} - .5), green($c), blue($c), .5)

The last rule is the one that gets applied in Edge, but is discarded due to the calc() result in Chrome and simply due to the use of calc() in Firefox, so we get the result we want this way.

Screenshot of the Chrome (left) and Firefox (right) dev tools showing how the second stroke rule is seen as invalid and discarded by both browsers
The second stroke rule seen as invalid in Chrome (left) and Firefox (right) dev tools.

However, this won't be the case anymore if the other browsers catch up with Edge here.

So a more future-proof solution would be to use @supports:

path {
  $c: hsl(random(360), 90%, 60%);
  stroke: rgba($c, .5);

  @supports (mix-blend-mode: multiply) { stroke: $c }
}

The second problem is that we want our strips to expand a bit beyond their end points. Fortunately, this problem has a straightforward fix: setting stroke-linecap to square. This effectively makes our strips extend by half a stroke-width beyond each of their two ends.

See the Pen by thebabydino (@thebabydino) on CodePen.

The final problem is that our sticky strips get cut off at the edge of our SVG. Even if we set the overflow property to visible on the SVG, the container our SVG is in might cut it off anyway or an element coming right after might overlap it.

So what we can try to do is increase the viewBox space all around the image by an amount we'll call p that's just enough to fit our sticky tape strips.

-// same as before
- var w1 = w + 2*p, h1 = h + 2*p;

svg(viewBox=[-p, -p, w1, h1].join(' '))
  -// same as before

The question here is... how much is that p amount?

Well, in order to get that value, we need to take into account the fact that our stroke-width is a % value. In SVG, a % value for something like the stroke-width is computed relative to the diagonal of the SVG region. In our case, this SVG region is a rectangle of width w and height h. If we draw the diagonal of this rectangle, we see that we can compute it using Pythagora's theorem in the yellow highlighted triangle.

SVG illustration showing the SVG rectangle and highlighting half of it - a right triangle where the catheti are the SVG viewBox width and height and the hypotenuse is the SVG diagonal.
The diagonal of the SVG rectangle can be computed from a right triangle where the catheti are the SVG viewBox width and height.

So our diagonal is:

- var d = Math.sqrt(w*w + h*h);

From here, we can compute the stroke-width as 5% of the diagonal. This is equivalent to multiplying the diagonal (d) with a .05 factor:

- var f = .05, sw = f*d;

Note that this is moving from a % value (5%) to a value in user units (.05*d). This is going to be convenient as, by increasing the viewBox dimensions we also increase the diagonal and, therefore, what 5% of this diagonal is.

The stroke of any path is drawn half inside, half outside the path line. However, we need to increase the viewBox space by more than half a stroke-width. We also need to take into account the fact that the stroke-linecap extends beyond the endpoints of the path by half a stroke-width:

SVG illustration showing how the stroke is drawn half on one side of the path line, half on the other side and how a square linecap causes it to expand by half a stroke-width beyond its endpoints.
The effect of stroke-width and stroke-linecap: square.

Now let's consider the situation when a point of our clipping polygon is right on the edge of our original image. We only consider one of the polygonal edges that have an end at this point in order to simplify things (everything is just the same for the other one).

We take the particular case of a strip along a polygonal edge having one end E on the top edge of our original image (and of the SVG as well).

Screenshot showing the result of the latest demo and highlighting an edge which has an end on the top boundary of the original image.
Highlighting a polygonal edge which has one endpoint on the top boundary of the original image.

We want to see by how much this strip can extend beyond the top edge of the image in the case when it's created with a stroke and the stroke-linecap is set to square. This depends on the angle formed with the top edge and we're interested in finding the maximum amount of extra space we need above this top boundary so that no part of our strip gets cut off by overflow.

In order to understand this better, the interactive demo below allows us to rotate the strip and get a feel for how far the outer corners of the stroke creating this strip (including the square linecap) can extend:

See the Pen by thebabydino (@thebabydino) on CodePen.

As the demo above illustrates by tracing the position of the outer corners of the stroke including the stroke-linecap, the maximum amount of extra space needed beyond the image boundary is when the line segment between the endpoint on the edge E and the outer corner of the stroke including the linecap at that endpoint (this outer corner being either A or B, depending on the angle) is vertical and this amount is equal to the length of the segment.

Given that the stroke extends by half a stroke-width beyond the end point, both in the tangent and in the normal direction, it results that the length of this line segment is the hypotenuse of a right isosceles triangle whose catheti each equal half a stroke-width:

SVG illustration showing how to get the segment between the path's endpoint and one of the outer corners of the stroke including the linecap: it's the hypotenuse in an isosceles triangle where the catheti are half a stroke width, along the normal direction because half the stroke is on one side of the path line while the other half is on the other and along the tangent direction because we have square linecap.
The segment connecting the endpoint to the outer corner of the stroke including the linecap is the hypotenuse in a right isosceles triangle where the catheti are half a stroke-width.

Using Pythagora's theorem in this triangle, we have:

- var hw = .5*sw;
- var p = Math.sqrt(hw*hw + hw*hw) = hw*Math.sqrt(2);

Putting it all together, our Pug code becomes:

/* same coordinates and initial dimensions as before */
- var f = .05, d = Math.sqrt(w*w + h*h);
- var sw = f*d, hw = .5*sw;
- var p = +(hw*Math.sqrt(2)).toFixed(2);
- var w1 = w + 2*p, h1 = h + 2*p;

svg(viewBox=[-p, -p, w1, h1].join(' ') 
    style=`--sw: ${+sw.toFixed(2)}px`)
  /* same as before */

while in the CSS we're left to tweak that stroke-width for the sticky strips:

stroke-width: var(--sw);

Note that we cannot make --sw a unitless value in order to then set the stroke-width to calc(var(--sw)*1px) - while in theory this should work, in practice Firefox and Edge don't yet support using calc() values for stroke-* properties.

The final result can be seen in the following Pen:

See the Pen by thebabydino (@thebabydino) on CodePen.