Skip to content

Instantly share code, notes, and snippets.

@Heydon
Created November 29, 2018 09:33
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Heydon/5a9a69421f2ac4b924f53849bd8b3b1c to your computer and use it in GitHub Desktop.
Save Heydon/5a9a69421f2ac4b924f53849bd8b3b1c to your computer and use it in GitHub Desktop.

Unusual Shapes

Ask any of my friends, and they'll tell you I'm a big fan of shapes. Here are some of my all time favorites:

  • The circle
  • The hexagon
  • The triangle
  • The 'play button' (a triangle on its side)
  • The square
  • The fast square (a skewed square)
  • And the unutterably beautiful exonandoboid

But, unfortunately, we don't see this kind of diversity on the web. Most web interfaces are made by identikit white men (like me) and most of the interfaces we make are made up entirely of rectangles.

It's not just through lack of imagination, though. Screens do tend to be rectangular — even when you turn them on their side, or hold them upside down. Handsets that skew are considered to have poor structural integrity.

Rectangles are a good fit inside other rectangles, like men are a good fit inside roles and occupations catering deliberately, though arbitrarily, to men. But other shapes literally do fit inside rectangles, so why not subvert this homogenous, boxy reality.

As the web evolves, we are enjoying the ability to create an increasingly diverse range of shapes. One of the earliest innovations was the border-radius property, which let you soften the corners of rectangles... even to the point they became circles (which are not rectangles at all).

The circular shape here is achieved using a 50% value: each corner is curved by 50% of the element's width, meaning no flat edges can remain.

div {
  border-radius: 50%;
}

Before border-radius, you had to create and position four images of curved corners over the four pointy ones. I once made a JavaScript plugin to achieve this. Those were dark days.

[pause]

If my rounded element appears within some text content, the text doesn't automatically 'hug' the shape. But we can fix that with the CSS Shapes specification.

Just to be perfectly clear, there are all sorts of CSS shapes that you can make (small 's'), but only one CSS Shapes (capital 's') specification, which specifically specifies how content wraps around CSS shapes (small 's' again).

Probably the easiest way to hug a circular shape (weird turn of phrase) is to supply the circle() function to the shape-outside property. The 50% value here denotes the radius of the invisible guide shape, and is based on the width of the parent element.

.circle {
  width: 10rem;
  height: 10rem;
  border-radius: 50%;
  float: left;
  margin: 1rem;
  shape-outside: circle(50%);
  shape-margin: 1rem; /* create a margin around the guide shape */
}

The shape-outside property also takes ellipse() and polgon() functions. For a complex shape of curvy areas and pointy bits like the exonandoboid, plotting points manually using the polygon() function is one way forward. Alternatively, the shape-outside property also takes a url() function. By supplying a path to the PNG of the exonandoboid, the browser can detect the transparent parts, and calculate the shape automatically.

.exonanoboidal {
  width: 10rem;
  height: 10rem;
  float: left;
  shape-ouside: url(/path/to/exonanoboid.png);
}

[pause]

What if you wanted to make your boxes more jagged rather than less? Jen Simmons' Layout Land video series suggests the clip-path property for this. Using the polygon function, we can reposition the corners a little. The point values come in pairs, representing the x and y coordinates for each.

.box {
  clip-path: polygon(0% 0%, 95% 5%, 93% 93%, 5% 93%);
}

You don't have to use percentage values for your coordinates. Sometimes, it's better to use rems or other units, and sometimes you'll want to combine percentages and other values. For example, the x coordinates for the second and fourth points in this arrow shape are each 100% minus 1rem. This way, the shape of the arrow head does not distort as the arrow's width is altered.

.arrow {
  clip-path: polygon(0 0, calc(100% - 1rem) 0, 100% 50%, calc(100% - 1rem) 100%, 0 100%);
}

Back to our jagged box shape. This shape is unusual looking on its own, but not if it's used over and over throughout a grid (yawn). To introduce some perceived randomness, we can define a few variations, and distribute them throughout the grid using the nth-child selector.

.box:nth-child(2n) {
  clip-path: polygon(5% 3%, 95% 0%, 100% 93%, 3% 100%);
}

.box:nth-child(3n) {
  clip-path: polygon(0% 3%, 100% 5%, 95% 100%, 0% 93%);
}

nth-child(2n) means elements that match multiples of two, and nth-child(3n) means elements that match multiples of, you guessed it, three. 2n overrides the default, and 3n overrides 2n, thanks to the ordering in the cascade. Each 'layer' punches holes in the otherwise orderly design.

Not bad. But a solution that's truly random (or, at least, truly random within sensible parameters) would need to use a random number generator. The most efficient way is to use a Houdini paint worklet: a specially optimized JavaScript worker for tapping into the browser's rendering process.

The first thing we need to establish is the maximum distance the points can be inset. We let the user define this value in the CSS, with the custom property, --max-inset. Note the paint function that applies the worklet we've yet to define.

.box {
  --max-inset: 1rem;
  background-image: paint(jagged-rectangle);
}

The --max-inset property is registered in JavaScript, with a syntax property to let the paint worklet know what kind of value it needs to interpret. In this case, it's a length value.

if ('registerProperty' in CSS) {
  CSS.registerProperty({
    name: '--max-inset',
    syntax: '<length>',
    initialValue: '16px'
  })
}

Next we need a function to choose a random value up to that maximum. Here's a simple one using the Math API.

this.integerBetween = (min, max) => { 
  return Math.floor(Math.random() * (max - min) + min)
}

All we need to do now is make the paint worklet plot and fill a path, using the integerBetween function to set each of the four randomized points. The size.width and size.height constants are expressions of the element's width and height. These are calculated for us, and recalculated automatically where the box's dimensions change.

ctx.beginPath();
ctx.moveTo(
  this.integerBetween(0, max), 
  this.integerBetween(0, max)
);
ctx.lineTo(
  this.integerBetween(size.width - max, size.width), 
  this.integerBetween(0, max)
);
ctx.lineTo(
  this.integerBetween(size.width - max, size.width), 
  this.integerBetween(size.height - max, size.height)
);
ctx.lineTo(
  this.integerBetween(0, max), 
  this.integerBetween(size.height - max, size.height)
);
ctx.closePath();
ctx.fill();

The randomized plotting happens on each repaint, so if you resize the viewport you get a rather eye-melting animation. All that's left for me to do, is choose a suitable sound effect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment