Skip to main content
Home / Articles /

Restricting a (pseudo) element to its parent’s border-box

Have you ever wanted to ensure that nothing of a (pseudo) element gets displayed outside its parent's border-box? In case you're having trouble picturing what that looks like, let's say we wanted to get the following result with minimal markup and avoiding brittle CSS.

Screenshot of the result we want to get, highlighting the fact that even though an element has both a padding and a border, its descendant gets clipped to the limit of its border.
The desired result.

This means we cannot add any elements just for visual purposes and we cannot create shapes out of multiple pieces, whether that's directly or via masks. We also want to avoid long, long lists of anything (think something like tens of background layers or box shadows or points inside a polygon() function) in our generated code because, while the results can be fun, it's not really practical to do something like that!

How do you think we can achieve this, given the parts the arrows point towards? Fancy giving it a try before checking my solution below? It's one of those things that seems simple at first, but once you actually try it, you discover it's much trickier.

Markup

Each item is a paragraph (<p>) element. I was lazy and generated them with Pug out of an array of objects which hold the item's gradient stop list and its paragraph text:

- var data = [
-   {
-     slist: ['#ebac79', '#d65b56'], 
-     ptext: 'Pancake muffin chocolate syrup brownie.'
-   }, 
-   {
-     slist: ['#90cbb7', '#2fb1a9'], 
-     ptext: 'Cake lemon berry muffin plum macaron.'
-   }, 
-   {
-     slist: ['#8a7876', '#32201c'], 
-     ptext: 'Wafer apple tart pie muffin gingerbread.'
-   }, 
-   {
-     slist: ['#a6c869', '#37a65a'], 
-     ptext: 'Liquorice plum topping chocolate lemon.'
-   }
- ].reverse();
- var n = data.length;

while n--
  p(style=`--slist: ${data[n].slist}`) #{data[n].ptext}

This generates the following unspectacular HTML:

<p style='--slist: #ebac79, #d65b56'>Pancake muffin chocolate syrup brownie.</p>
<p style='--slist: #90cbb7, #2fb1a9'>Cake lemon berry muffin plum macaron.</p>
<p style='--slist: #8a7876, #32201c'>Wafer apple tart pie muffin gingerbread.</p>
<p style='--slist: #a6c869, #37a65a'>Liquorice plum topping chocolate lemon.</p>

Basic styles

For the paragraph elements, we set a font, dimensions and a border-radius that's half the height value:

$w: 26em;
$h: 5em;

p {
  width: $w; height: $h;
  border-radius: .5*$h;
  background: silver;
  font: 1.5em/ 1.375 trebuchet ms, verdana, sans-serif;
}

We've also set a dummy background so we can see their limits:

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

Paragraph background

We have three top-to-bottom gradients, which means we can place each of them within the limits of a different layout box: the top gradient layer is limited to the content-box, the middle one to the padding-box and the bottom one to the border-box. If you need an in-depth refresher on this technique, check out this article, but the basic idea is you picture these layout boxes as nested rectangles.

Illustration showing the layout boxes. The outermost box is the border-box. Inside it, a border-width away from the border limit, we have the padding-box. And finally, inside the padding-box, a padding away from the padding limit, we have the content-box.
The layout boxes. (Demo)

This is pretty much how browser DevTools presents them.

Screenshot collage showing the graphical representation of the layout boxes in browsers' DevTools.
The layout boxes as shown by Chrome (left) vs. Firefox (middle) vs. Edge (right).

You may be wondering why we wouldn't layer gradients with different sizes given by their background-size and that have background-repeat: no-repeat. Well, this is because we only get rectangles without rounded corners this way.

Using the background-clip method, if we have a border-radius, our background layers will follow that. Meanwhile, the actual border-radius we set is being used to round the corners of the border-box; that same radius minus the border-width rounding the corners of the padding-box. Then we're subtracting the padding as well to round the corners of the content-box.

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

So let's code this!

We set a transparent border and a padding. We make sure they get subtracted from the dimensions we've set by switching to box-sizing: border-box. Finally, we layer three gradients: the top one restricted to the content-box, the middle one to the padding-box and the bottom one to the border-box.

p {
  /* same styles as before */
  display: flex;
  align-items: center;
  justify-content: space-between;
  box-sizing: border-box;
  border: solid $b transparent;
  padding: $p;
  background: 
    linear-gradient(#dbdbdb, #fff) content-box, 
    linear-gradient(var(--slist)) padding-box, 
    linear-gradient(#fff, #dcdcdc) border-box;
  text-indent: 1em;
}

We've also set a flex layout and a text-indent to move the text content away from the banner edges:

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

Numbering

Before we move on to the tricky part, let's get the paragraph numbers out of the way!

We add them using a counter we set as the content value on the :after pseudo-element. We first make this :after a square whose edge length equals the paragraph height (which is $h) minus the top and bottom border-width (both equal to $b). Then we turn this square into a circle by setting border-radius: 50% on it. We make it inherit its parent's box-sizing and border and we then set its background in a similar manner as we did for its parent.

$d: $h - 2*$b;

p {
  /* same styles as before */
  counter-increment: c;

  &:after {
    box-sizing: inherit;
    border: inherit;
    width: $d; height: $d;
    border-radius: 50%;
    box-shadow: 
      inset 0 0 1px 1px #efefef, 
      inset 0 #{-$b} rgba(#000, .1);
    background: 
      linear-gradient(var(--slist)) padding-box, 
      linear-gradient(#d0d0d0, #e7e7e7) border-box;
    color: #fff;
    content: counter(c, decimal-leading-zero);
  }
}

Alright, this is starting to look like something!

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

We still need to make a few tweaks to the CSS of this :after pseudo-element — a margin-right that's minus its parent's padding and adjustments to its inner layout so we have the number dead in the middle. That's pretty much it for the numbering part!

p {
  /* same styles as before */

  &:after {
    /* same styles as before */
    display: grid;
    place-content: center;
    margin-right: -$p;
    text-indent: 0;
  }
}

We're getting closer!

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

The tricky part

And we're finally here!

We start off by using the :before pseudo-element, absolutely positioning it on the right side and making it a square whose edge length equals its parent's height:

p {
  /* same styles as before */
  position: relative;
  outline: solid 2px orange;

  &:before {
    position: absolute;
    right: -$b;
    width: $h;
    height: $h;
    outline: solid 2px purple;
    content: '';
  }
}

We've also given both this pseudo-element and its parent some dummy outlines so that we can check the alignment:

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

Alright, we now give this :before a dummy background, rotate it, and then give it a border-radius and a nice box-shadow:

p {
  /* same styles as before */

  &:before {
    /* same styles as before */
    border-radius: $b;
    transform: rotate(45deg);
    box-shadow: 0 0 7px rgba(#000, .2);
    background: linear-gradient(-45deg, orange, purple);
  }
}

And we get the following result!

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

We now have a small problem: the :before pseudo-element is absolutely positioned and is now on top of the :after pseudo-elements that holds the numbering! We can fix this by setting position: relative on the :after pseudo-element.

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

Here's where things start getting interesting!

Restricting the background gradient

First, we need to set the stop positions on the gradient of our :before pseudo-element such that they match the bottom and top edges of the parent. This is because we want to have a certain hex value along the top edge of the parent and a certain hex value along the bottom edge of the parent.

Annotated illustration. Shows the parent paragraph and its rotated :before pseudo-element, the gradient direction and the stop lines at the positions we're looking for.
The stop positions we need to compute.

Since we've rotated our square :before by 45°, its top-left corner now points upward (and, conversely, its bottom-right corner points downward).

Animated .gif. Shows the square :before positioned on the right of its parent. Its top left corner and bottom right corner are highlighted as well as its vertical axis (vertical line passing through the intersection of its diagonals). Rotating our square by 45° means its top left corner now points up (and its bottom right corner points down).
How rotation changes position of the square's corners.

A gradient to the top-left corner of a square is a gradient in the -45° direction (because the angle is at 12 o'clock and the positive direction is, just as for transforms, the clockwise one). A gradient to a corner means the 100% point is located in that corner)

Animated .gif. Shows the square :before positioned on the right of its parent. Shows the linear gradient to the top left corner. After the rotation, since the top left corner points up, the gradient direction also goes up.
How rotation changes the gradient direction.

The 50% line of a gradient always passes through the midpoint (the point at the intersection of the diagonals) of the gradient box.

The gradient box is the box within which we paint the gradient and whose size is given by the background-size. Since we haven't set a background-size, the default for gradients is to use the entire box defined by background-origin, which is the padding-box by default. Since we don't have a border or a padding on our :before pseudo-element, it results that all three boxes (content-box, padding-box and border-box) are equal in space between them and equal in proportion to the gradient box.

In our case, we have the following lines perpendicular to the direction of the -45°-pointing gradient line:

Annotated illustration. Shows the parent paragraph and its rotated :before pseudo-element, the gradient direction and the stop lines at 0% and 100%, at the two positions we want to get and at 50%.
Getting the relevant stop positions.
  • the 0% line, passing through the bottom right corner of the :before
  • the bottom edge of the pseudo-element's paragraph parent
  • the 50% line which splits our square diagonally into two mirrored right isosceles triangles; given the way we've aligned our paragraph and its pseudo-elements, this line is also a midline for the paragraph itself, splitting it into two halves, each with a height equal to half the paragraph's height ($h).
  • the top edge of the pseudo-element's paragraph parent
  • the 100% line, passing through the top left corner of the :before

This means we need to restrict the -45°-pointing gradient on our :before pseudo-element between calc(50% - #{.5*$h}) (corresponding to the paragraph's bottom edge) and calc(50% + #{.5*$h}) (corresponding to the paragraph's top edge).

Sure enough, this does it!

linear-gradient(-45deg, orange calc(50% - #{.5*$h}), purple calc(50% + #{.5*$h}))

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

Adding a sharp transition from and to transparent at these stop positions makes it a lot more obvious they're the correct ones:

linear-gradient(-45deg, 
      transparent calc(50% - #{.5*$h}), orange 0, 
      purple calc(50% + #{.5*$h}), transparent 0)

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

Restricting the pseudo-element itself

The next step is to prevent the :before pseudo-element from spilling outside the boundaries of its parent.

That's easy, right? Just set overflow: hidden on the paragraph!

Well, let's do that!

This is the result we get:

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

Oops, this is not what we were going for!

Side by side comparison of what we have using overflow: hidden (:before gets clipped to its parent's padding-box) and what we actually want to get (:before gets clipped to its parent's border-box).
What we got with overflow: hidden (left) vs. what we want (right).

The problem is overflow: hidden cuts out everything outside an element's padding-box, but what we want here is to cut the parts of the :before pseudo-element that are outside the border-box, which is larger than the padding-box in our case because we have a non-zero border we cannot ditch (and solve the issue by making the border-box equal to the padding-box) because we need three background layers on our paragraph: the top one covering the content-box, the middle one covering the padding-box and the bottom one covering the border-box.

The solution? Well, if you've taken a peek at the tags, you've probably guessed by now: use clip-path instead!

Pretty much every article and demo using clip-path out there makes use of either an SVG reference or the polygon() shape function, but these are not the only options we have!

Another possible shape function (and the one we'll be using here) is inset(). This function specifies a clipping rectangle defined by the distances from the top, right, bottom and left edges. Edges of what? Well, by default1, that's the edges of the border-box, which is exactly what we need here!

Illustration showing what the four values of the inset() function represent. The first one is the offset of the top edge of the clipping rectangle with respect to the top edge of the border-box. The second one is the offset of the right edge of the clipping rectangle with respect to the right edge of the border-box. The third one is the offset of the bottom edge of the clipping rectangle with respect to the bottom edge of the border-box. The fourth one is the offset of the left edge of the clipping rectangle with respect to the left edge of the border-box.
How the inset() function works. (Demo)

So let's ditch overflow: hidden and use clip-path: inset(0) instead. This is the result we get:

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

This is better, but not quite what we want because it doesn't take into account the paragraph's border-radius. Fortunately, inset() also lets us specify a rounding that can take any border-radius value we want. No joke, any valid border-radius value works — for example, this:

clip-path: inset(0 round 15% 75px 35vh 13vw/ 3em 5rem 29vmin 12.5vmax)

We only need something much simpler though:

$r: .5*$h;

p {
  /* same styles as before */
  border-radius: $r;
  clip-path: inset(0 round $r)
}

And now we finally get the result we wanted:

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

Final touches

Since we don't want a purple-orange gradient on the :before, we replace those with the actual values we need. We then place the paragraphs in the middle because that looks better. Finally, we give our paragraphs a shadow by setting a drop-shadow() on the body (we cannot use box-shadow on the paragraphs themselves because we've used clip-path, which clips out the box-shadow so we wouldn't see it anyway). And that's it!

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


  1. We should be able to change this <geometry-box> value, but Chrome does not implement this part of the spec. There is an issue open for this which you can star or where you can leave a comment with your use cases for being able to change the default value.
icon-link icon-logo-star icon-search icon-star