Cutting out the inner part of an element using clip-path

The following is a guest post by Ana Tudor. Perhaps you know Ana from her amazing work combining code, math, and art. Here, she shows us how we can change the normal behavior of clipping paths by applying some clever geometry, and then make it work across different technology and browsers.

The clip-path property in CSS is used to hide areas of an element outside the path. But we can also cut out an area inside the element this way. The same can be achieved with masking by reversing the fill colors, but what I'm aiming for here is to show how this effect can be achieved with clip-path.

The path needs to have two parts:

  • an outer one which makes sure the area inside it stays visible
  • a inner one in order to remove a part of the area inside the outer path

The two need to be connected by a zero width tunnel. No matter what the eyes think they see, everything is still inside of the path.

The demo below shows how the path is drawn and how expanding the width of the tunnel reveals that what looks like a border is actually the area inside the path. Note how the outer part is drawn counterclockwise, while the inner one is being drawn clockwise.

See the Pen.

Alright, let's see how we can CSS this. The only basic shape we can use is polygon(). So we're going to have something like this:

clip-path: polygon(
  /* points of the outer triangle going counterclockwise */
  285px 150px, 83px 33px, 83px 267px, 
  
  /* return to the first point of the outer triangle */
  285px 150px, 
  
  /* points of the inner triangle going clockwise */
  258px 150px, 96px 244px, 96px 56px, 
  
  /* return to the first point of the inner triangle */
  258px 150px
);

You can see it working in this pen (WebKit/Blink browsers and Firefox 47+ with layout.css.clip-path-shapes.enabled set to true in about:config):

See the Pen.

That's a lot of code to write manually. And there is a lot more code if, instead of a triangle, we want to have a dodecagon. Which is why we can use Sass to automate the generation of the list of points for regular polygons (polygons having all edges equal and all vertices equal).

Let's see how that works.

As you can see in the demo below, all vertices of a regular polygon are situated on a circle called the circumcircle.

See the Pen in/circumcircle of a regular polygon (SVG version) by Ana Tudor (@thebabydino) on CodePen.

In order to get a regular polygon with n edges/vertices, we need to evenly distribute the n vertices on a circle and then connect the first with the second, the second with the third and so on, until we connect the last one back to the first. But how do we distribute the points on the circle?

The position of a point on a circle can be described by the radius of the circle and by the angle formed between the radius connecting the point to the centre of the circle and the radius along the x axis in the positive direction.

As the image above shows, the x coordinate is the radius times the cosine of angle it forms with the horizontal. The y coordinate is the radius times the sine of the same angle. We'll take the outer radius to be an arbitrary value that's at most half the minimum dimension of the element we want clipped and the inner one somewhat smaller. But what is the angle for each vertex?

If we place the first vertex at angle 0, we distribute all vertices evenly on the circle and there are 360 degrees around the circle, then the angle corresponding to the ith vertex of a polygon with n vertices in total is going to be i*360deg/n.

So our Sass code starts out by setting a value greater or equal to 3 for the number of vertices, computing the base angle (360deg/n), creating the outer and inner radius variables, the x and y offset variables - these will specify the position of the circumcentre - and the empty point lists for both the inner and the outer triangles.

$n: 3;
$base-angle: 360deg/n;
$r-outer: 150px;
$r-inner: 120px;
$offset-x: 50%;
$offset-y: 50%;
$points-inner: ();
$points-outer: ();

We then loop through the vertices, compute the coordinates for each and add them to the corresponding list:

@for $i from 0 through $n {
  $points-outer: append(
    /* list of points for the outer polygon*/
    $points-outer, 

    /* x coordinate of current outer vertex */
    calc(#{$offset-x} + #{$r-outer*cos(-$i*$base-angle)}) 

    /* y coordinate of current outer vertex */
    calc(#{$offset-y} + #{$r-outer*sin(-$i*$base-angle)}), 
  
    comma) !global;

  $points-inner: append(
    /* list of points for the inner polygon*/
    $points-inner, 

    /* x coordinate of current inner vertex */
    calc(#{$offset-x} + #{$r-inner*cos($i*$base-angle)}) 

    /* y coordinate of current inner vertex */
    calc(#{$offset-y} + #{$r-inner*sin($i*$base-angle)}), 
  
    comma) !global;
}

There is a few important things here to consider. First, when looping, we have $i from 0 through $n, meaning we add $n + 1 sets of coordinates to our lists. This is not a mistake, because for both the outer and the inner polygon, we need to return to the first vertex to connect them through the zero width tunnel. Second, the current angle for each vertex is -$i*$base-angle in the case of the outer polygon and $i*$base-angle in the case of the inner polygon. This is because the outer one goes counterclockwise and the inner one clockwise.

At this point, we have two lists containing the vertices of the outer polygon and those of the inner polygon respectively. Now all we need to do is to join them when putting them inside the polygon function.

clip-path: polygon(join($points-outer, $points-inner));

You can play with it live in this Pen. Change the values of $n to see how the polygon changes (WebKit/Blink browsers and Firefox 47+ with layout.css.clip-path-shapes.enabled set to true in about:config):

See the Pen.

For Firefox, we need to use a reference to an SVG <clipPath> element. We would put this in the HTML:

<svg width='0' height='0'>
  <defs>
    <clipPath id='cp'>
      <path d='M285,150 L83,33 L83,267 
               L285,150
               L258,150 L96,244 L96,56
               L258,150z' />
    </clipPath>
  </defs>
</svg>

Here, the first line of the path data contains the coordinates of the outer polygon vertices, the second one the coordinates of its first vertex, the third one contains the coordinates of the inner polygon vertices and the fourth one repeats the coordinates of the first vertex of this second polygon.

And in the CSS we'll simply have:

clip-path: url(#cp);

This works in Firefox now, as you can see in this demo:

See the Pen.

However, there are a number of things to keep in mind here.

We cannot mix units like we can inside the polygon() function. The numbers in the path data are hardcoded and not flexible at all. Also, as it was noted before, Chrome/ Opera take the 0,0 point for our clip-path to be the top left of the screen, not the top left of the element we want to clip. We can get past this by changing the value of the clipPathUnits attribute from userSpaceOnUse (the default) to objectBoundingBox. Doing this forces us to change the values in the path, which should now all be scaled to the [0, 1] interval. Changing transforms on the clipped element is still buggy in Chrome:

See the Pen.

As for making this flexible, all we need to do is recreate with JavaScript what we have done with Sass.

var n = 3, 
    base_angle = 2*Math.PI/n, 
    r_outer = .5, 
    r_inner = .4, 
    offset_x = .5, 
    offset_y = .5, 
    points_outer = '', 
    points_inner = '', 
    angle, x, y;

for(var i = 0; i <= n; i++) {
  angle = i*base_angle;
  x = Math.cos(angle);
  y = Math.sin(angle);
  
  points_outer += ((i === 0)?'M':' L') + 
    (offset_x + r_outer*x).toFixed(3) + ', ' + 
    (offset_y - r_outer*y).toFixed(3);
  points_inner += ' L' + 
    (offset_x + r_inner*x).toFixed(3) + ', ' + 
    (offset_y + r_inner*y).toFixed(3);
}

document.querySelector('#cp path').setAttribute('d', points_outer + points_inner + 'z');

If you're wondering why we have - r_outer*y while the other three similar terms are with +, it's again because of the fact that the outer and inner polygons have to be drawn in opposite directions (chosen here to be counterclockwise for the outer polygon and clockwise for the inner one). This means we should use -angle at each step to compute the positions of the vertices for the outer polygon and angle for the inner one. But cos(angle) = cos(-angle) (same sign), while sin(angle) = -sin(-angle) (opposite signs). Since we've stored cos(angle) in x and sin(angle) in y, we have the coordinates of the current point relative to the offset: r_outer*x, r_outer*-y for the outer polygon and r_inner*x, r_inner*y for the inner one.

You can play with it all in this Pen (changing the value of the n variable changes the entire polygon):

See the Pen.

If you want to get the same visual result in IE as well, then the clipped element should be an SVG element. The initial markup becomes:

<svg width='300' height='300'>
  <defs>
    <clipPath id='cp' clipPathUnits='objectBoundingBox'>
      <path d='' />
    </clipPath>
  </defs>
  
  <rect class='clip-me' width='300' height='300'/>
</svg>

And you can play with it in this Pen:

See the Pen.

There are more things you can use the zero width tunnel method for. One example is cutting out an area in the middle of an element.

See the Pen.

Or you could use it to clip out everything with the exception of a few unconnected areas. This is pretty simple to achieve anyway when using a reference to a <clipPath> element, but a zero width tunnel between regions is the only way to get this result when using the polygon() value in CSS.

See the Pen.

And of course, there's always the option of multiple polygons with the inside cut out.

See the Pen.