Introduction
As we already commented in a previous article, in one of our last projects we were asked to create inverted corners for teasers. Organic shapes are normally quite complex to achieve in a world of a square box model, so, it is always challenging to achieve results that works in most of the browsers in a performant way, with as few side effects as possible.
So, before starting implementing code and losing time in trial and error methods, let’s try to analyse what are the differences between the previous version and the current one:
There is a card that has an inverted corner on the right side, leaving some space for the green rounded CTA. In this version of the designs, that button doesn’t grow, so, this is quite lucky in our end.
The width of the card depends on the container, because it can be placed in one, two or three column grid, and should be responsive. In conclusion, the card size is not fixed. The height is not either fixed since it depends on the card text section.
The cards can be placed in different sections of the website, and those sections can have different background colors. This is something important to have in mind.
Ok, after checking the requisites (now that the growing button is out of the current scope), it is recommendable to look for existing solutions instead of reinventing the wheel or start to implement solutions blindly falling into a vicious circle of unsuccessful attempts.
Despite we mentioned them slightly in the previous article, let's look at them in more detail. In summary, four common traditional approaches can be found:
- Using pseudoelements
- Using clip-path or background-clip
- Using an image as a mask
- Using CSS Mask layers
Let’s analyse all of them to know which could be the most suitable for our project.
1) Using pseudoelements
Probably the most common solution and the easiest to implement. You can find some examples on the internet like:
Basically, the trick consists in creating an area at the bottom right of the teaser, positioned absolute, with the same background color than the container and a border radius. Then you can place two pseudoelements at the top right and bottom left corners of the inverted area with box-shadow to generate inverted corners.
In this example above, you can see the two pseudoelements that uses the box-shadow (in blue for learning purpose) and the inverted area with the border radius on the top left corner (in red).
Let’s analyse the pros and cons.
Strong points:
- Responsive, since we have complete control of the inverted area.
- Easily animatable if we want
- Performant
- Easy and smooth to implement
- Supported by all browsers
Weak points:
- You need to know the background-color to mach the container element, otherwise is quite noticeable.
- You need to add a overflow hidden to the card element to avoid the pseudoelements overflow the card size. If there are other overflowing decorations could be cropped and you need to refactor your code.
This is quite unfortunate, because we don’t know the background-color beforehand since the teasers can be placed in a lot of different sections. A possible fix for this could be defining a css variable inside the parent section that it is parsed to the card. It would be reasonable, although, it is not the best option because it requires extra efforts that we didn’t estimate since we are refactoring a complete website. We would need to edit lots of component templates to make sure that the css variable is correctly parsed. There should be another moder solution that can work for us.
2) Clip-path or background-clip
This solution is quite promising. You can find an example by @CSSnippets on https://www.youtube.com/watch?v=yXN6MT4ERbE&pp=ygUfY3NzbmlwcGV0cyBjcmVhdGUgY3VzdG9tIHNoYXBlcw%3D%3D
The main goal of this solution is to create a shape using the property clip-path. It requires a bit of space vision in order to define the complex shape. It seems complicated in terms of code, but is intuitive defining the coordinates.
Let’s do the same than the previous solutions:
Strong points:
- Quite performant
- Supported by most of the browsers in their recent versions
- Even less code is required than the previous solution
Weak points:
- Since the coordinates of the shape are given in ordinary units, the size of the shape should be fixed. You can’t work with percentages.
That’s dissapointing since we have cards that can grow or shrink in size.
It is true that there are other options available for clip-path like polygons, circles, etc. However, our shape is too much complex to be achieved defining clip-paths. We coudln't achieve it despite there are some online tools that could make our lives easier: https://corner-inverter.douiri.org/
3) Using an image as mask
This solution is quite common too, you can see one example on this website: https://www.w3schools.com/css/css3_masking.asp
The results seems to be promising too: performant, modern and it is not necessary to use too much code.
Strong points:
- Easy and fast to implement
- Fewer lines of code
- Quite performant
- Total crossbrowser compatibily
- The image can be defined as svg for using less space on disk
Weak points:
- If the teaser resizes, the mask image resizes too, so the inverted areas grow or shrink and we loose the control
- If you define the size of the mask to be fixed, you probably will crop the content of the card.
Ugh, damn, none of the solutions worked as easy that we wanted.
However, solution number 3 can guide us to the forth possible solution.
4) CSS Mask Layers
This is a further implementation of number 3. Given that css mask allow to add multiple css images, we can stack layers of mask that could compound the complex shape. Wow, it is more complex than we though but it could work.
This video from @DevlyCode is a good example: https://www.youtube.com/watch?v=dbaOq7F5msU
You only need to define properly the css masks using a layer logic, adding or substracting the shapes that you need from the teaser.
Strong points:
- Complete control of the layers, they can be placed with fixed size independently from the teaser size
- Partially animatable (mask-position, mask-size)
- Good browser support
- Performant
- Modern
Weak points:
- Requires more code
- More complex to implement
Yay! This is what we were looking for. We defined the shape and then we created a mixin that could parse the width and the height of the inverted area and also the border-radius.
@mixin cutout($radius, $width, $height) {
mask:
/* Layer 1: Bottom left forner (exclude) */
calc(100% - #{$width}) 100% / #{$radius} #{$radius} no-repeat exclude radial-gradient(100% 100% at 0 0, #0000 100%, #000 calc(100% + 1px)),
/* Layer 2: Top right corner (exclude) */
100% calc(100% - #{$height}) / #{$radius} #{$radius} no-repeat exclude radial-gradient(100% 100% at 0 0, #0000 100%, #000 calc(100% + 1px)),
/* Layer 3: Top left corner (include) */
radial-gradient(100% 100% at 100% 100%, #0000 100%, #000 calc(100% + 1px)) calc(100% - #{$width} + #{$radius}) calc(100% - #{$height} + #{$radius}) / #{$radius} #{$radius} no-repeat,
/* Layer 4: Conic for the irregular shape at the bottom right (include) */
conic-gradient(from 90deg at right #{$width} bottom #{$height}, #0000 25%, #000 0);
}
The logic behind is to substract (exclude) the inverted corners and add (include) the others.
Great, this could be reusable and easy to implement. The initial results looked amazing.
However, when we did some tests on the real environment, we discovered two horrible truths:
- Subpixel calculation can make appear weird blank lines around the shapes (browsers usually manage badly how to round subpixels natively).
- Google Chrome has bugs dealing with exclude masks (this is a historical bug that it still persists even in the latest versions).
Argh, we were so close!
The solution, refactoring the shape
Solution 4 was the most suitable for us for the moment, but we need to deal with these unexpected issues.
Subpixel calculation can be fixed relatively easy moving the shapes 1px to the left/right or top/bottom.
The tricky part is doing an analysis of our shape and splitting it in smaller parts that can be only added (or summed), not excluded (or substracted) since Chrome has issues mainly substracting masks. In short: instead of subtracting shapes, we reconstruct the card entirely using additive mask layers.
We could splice the card in 7 areas / layers like this (from L1 to L7, these areas are not reflecting the real size, it is merely orientative):
Ok, cool. We can define L1, L2, L4 and L6 as normal rectangles. L3 and L7 would be normal rounded corners, only L5 would be an inverted corner.
Fine, let's define the rounded corners and inverted corners as svgs using data:image to avoid possible issues with sharped or pixelated circles.
$round-corner: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><path fill="black" d="M400,0 H0 V400 C200,400 400,200 400,0 Z"/></svg>');
$inverted-corner: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><path d="M0,0 h400 a400,400 0 0 0 -400,400 z" fill="black"/></svg>');
The next step then it would be define the complete mask-image property adding all the shapes that we have defined (rectangles + rounded corners + inverted corner):
mask-image:
/* L1 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L2 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L3 */
#{$round-corner},
/* L4 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L5 */
#{$inverted-corner},
/* L6 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L7 */
#{$round-corner},
;
Great, next step would be define the size of each layer. Let's assume that we would need to make this generic and we would use a variable called $radius for the curve radius:
mask-size:
/* L1 */
100% 100%,
/* L2 */
100% 100%,
/* L3 */
#{$radius} #{$radius},
/* L4 */
100% 100%,
/* L5 */
#{$radius} #{$radius},
/* L6 */
100% 100%,
/* L7 */
#{$radius} #{$radius}
;
Uff, it's getting complicated. Since we can have different space around the arrow button, we need to define two another variables to define the width and the height of that empty space at the bottom right part of the card. We'll simple call them $width and $height. For calculations, we would need to remove or add 1px for fixing subpixel calculation, in some cases even 2px:
mask-position:
// Removing / adding 1px for fixing subpixel calculation.
/* L1 */
right 0 bottom calc(#{$height} + #{$radius} - 2px),
/* L2 */
right calc(#{$radius} - 1px) bottom calc(#{$height} - 1px),
/* L3 */
right 0 bottom calc(#{$height} - 1px),
/* L4 */
right calc(#{$width} - 1px) bottom calc(#{$radius} - 1px),
/* L5 */
right calc(#{$width} - #{$radius}) bottom calc(#{$height} - #{$radius} + 1px),
/* L6 */
right calc(#{$width} + #{$radius} - 2px) bottom 0,
/* L7 */
right calc(#{$width} - 1px) bottom 0
;
As you can see, the left part of each declaration is the right position and the second one is related to the distance from the bottom. Now we can complete the code with some mask-repeat properties.
mask-repeat:
no-repeat,
no-repeat,
no-repeat,
no-repeat,
no-repeat,
no-repeat,
no-repeat
;
Since we can have different sizes for the radius and the bottom right corner, it would be great if we create a mixin that we could reuse in any needed place and we could use it inside different media queries. The complete result would be:
@mixin cutout($radius, $width, $height) {
/**
* Mask Logic
*
* The mask has 7 layers (from L1 to L7) distributed using the below layout:
*
* ┌─────────────────────────────┐
* │ │
* │ L1 │
* │ │
* ├────────────────────────┬────┤
* │ L2 │ L3 │
* ├───────────────────╭────┴────╯
* │ L4 │ L5
* ├──────────────┬────┤
* │ L6 │ L7 │
* └──────────────┴────╯
*
* L1, L2, L4 and L6 are rectangles.
* L3, L4 and L7 are circles.
*
*
* CSS Properties are defined from top to bottom (L1 -> L7).
* All the layers are added, not excluded, in order to avoid chrome mask exclusion bugs.
*/
$round-corner: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><path fill="black" d="M400,0 H0 V400 C200,400 400,200 400,0 Z"/></svg>');
$inverted-corner: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><path d="M0,0 h400 a400,400 0 0 0 -400,400 z" fill="black"/></svg>');
mask-image:
/* L1 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L2 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L3 */
#{$round-corner},
/* L4 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L5 */
#{$inverted-corner},
/* L6 */
linear-gradient(0deg,#000000 0%, #000000 100%),
/* L7 */
#{$round-corner},
;
mask-size:
/* L1 */
100% 100%,
/* L2 */
100% 100%,
/* L3 */
#{$radius} #{$radius},
/* L4 */
100% 100%,
/* L5 */
#{$radius} #{$radius},
/* L6 */
100% 100%,
/* L7 */
#{$radius} #{$radius}
;
mask-position:
// Removing / adding 1px for fixing subpixel calculation.
/* L1 */
right 0 bottom calc(#{$height} + #{$radius} - 2px),
/* L2 */
right calc(#{$radius} - 1px) bottom calc(#{$height} - 1px),
/* L3 */
right 0 bottom calc(#{$height} - 1px),
/* L4 */
right calc(#{$width} - 1px) bottom calc(#{$radius} - 1px),
/* L5 */
right calc(#{$width} - #{$radius}) bottom calc(#{$height} - #{$radius} + 1px),
/* L6 */
right calc(#{$width} + #{$radius} - 2px) bottom 0,
/* L7 */
right calc(#{$width} - 1px) bottom 0
;
mask-repeat:
no-repeat,
no-repeat,
no-repeat,
no-repeat,
no-repeat,
no-repeat,
no-repeat
;
}
Yes! It worked, we have now a solid, stable, responsive, aseptic and generic solution for our cards, without artifacts or weird effects. The results are awesome.
As you can see, the CSS Masks Layers solution is working, however, the obtained mixing is long and quite difficult to maintain. Therefore, obvious questions arise, like the following ones:
What about the future? Are we doomed to use so complex techniques?
Despite we have found a solution for this kind of situations, CSS is still evolving and a new property is being implemented called corner-shape. This new property allows to control the shape of each corner, giving a fancier, stylish and more organic aesthetic without the need of doing complex implementations. This evolution reflects a broader trend: moving from hack-based composition toward declarative shape control in CSS.
You can check directly the results in this code pen:
In near future, we could combine different types of shapes to achieve the goal that the client defined for us, merely with a few lines of css code. We can even combine different shapes for each corner.
It is true that for achieving a more complex shape we'd need to combine this new property with old techniques, but the results are awesome and easy to implement. And if we define them as mixins, they can be as resusable or responsive as we woud like.
And now the question is, why didn't we apply directly this solution? Unfortunately because of browser support:
As you can see, the compatibility right now is quite limited to chromium browsers. Nothing for Firefox and Safari, and that's not acceptable for our clients. However, without any doubt, we'll use this feature as soon as it would be completely compatible for all browsers.
Conclusions
In conclusion, creating inverted corners in a flexible, responsive, and production-ready way highlights the gap between visually simple designs and their technical implementation in CSS. While traditional approaches like pseudo-elements, clip-path, or static masks can solve specific scenarios, they quickly break down under dynamic constraints such as unknown backgrounds or fluid layouts. By leveraging layered CSS masks and rethinking the problem as a composition of additive shapes rather than subtractive ones, we achieve a robust and reusable solution—albeit at the cost of increased complexity and careful handling of rendering quirks like subpixel issues and browser inconsistencies. Ultimately, this approach demonstrates that modern CSS is powerful enough to tackle highly custom UI challenges today, while also pointing toward a future where emerging properties like corner-shape may drastically simplify these patterns.