Skip to content

Fancy frames with CSS

Published
Reading time
19 minutes
Is your website a bit too rectangular? Do you want to break out of the box? Follow me as I embark on my toughest adventure yet, to make corners not square.

Introduction

Welcome to the first post on my brand new shiny blog! In the classic form of over-engineered personal websites my first post is going to be about the construction of this thing, but restricted to one decorative detail that I had a lot of fun with.

I love designing and making websites but one thing that always makes me sad is the abundance of rectangles. I wanted a design element (perhaps an ongoing brand motif?) that would add a bit more visual interest without distracting from the content. I decided to add a little pizzazz by highlighting the corners of the boxes; this post explains the technique I use to achieve that effect and several alternative solutions I discovered along the way. Strap in.

The ideal solution has two requirements:

CSS gradients

The first method I thought of for achieving this kind of effect was using layered gradient background images. To make scalloped cutout corners we’d need a radial-gradientradial-gradient in each corner, and to allow the box to be any aspect ratio we’d have to add a horizontal and vertical linear-gradientlinear-gradient to cover the inside:

.frame {
  --corner: 1em;
  --bg: white;
  background-image:
    radial-gradient(calc(2 * var(--corner)) at    0    0, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%), /* Safari will break if we put 100% */
    radial-gradient(calc(2 * var(--corner)) at    0 100%, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%),
    radial-gradient(calc(2 * var(--corner)) at 100% 100%, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%),
    radial-gradient(calc(2 * var(--corner)) at 100%    0, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%),
    linear-gradient(to  right, transparent var(--corner), var(--bg) var(--corner), var(--bg) calc(100% - var(--corner)), transparent calc(100% - var(--corner))),
    linear-gradient(to bottom, transparent var(--corner), var(--bg) var(--corner), var(--bg) calc(100% - var(--corner)), transparent calc(100% - var(--corner)));
}
.frame {
  --corner: 1em;
  --bg: white;
  background-image:
    radial-gradient(calc(2 * var(--corner)) at    0    0, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%), /* Safari will break if we put 100% */
    radial-gradient(calc(2 * var(--corner)) at    0 100%, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%),
    radial-gradient(calc(2 * var(--corner)) at 100% 100%, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%),
    radial-gradient(calc(2 * var(--corner)) at 100%    0, transparent 50%, var(--bg) 50%, var(--bg) 99.99%, transparent 99.99%),
    linear-gradient(to  right, transparent var(--corner), var(--bg) var(--corner), var(--bg) calc(100% - var(--corner)), transparent calc(100% - var(--corner))),
    linear-gradient(to bottom, transparent var(--corner), var(--bg) var(--corner), var(--bg) calc(100% - var(--corner)), transparent calc(100% - var(--corner)));
}

This does work okay but it has some restrictions – the dimensions of the box must be at least 3 times the corner size, and it doesn’t support semi-transparent or image backgrounds. It’s also quite a mouthful of code and only works for this one specific effect, any other corner design would require a lot more clumsy gradient maths.

CSS border images

…should have been my first port of call. border-imageborder-image allows you to specify an image and how it’s sliced up and stretched/repeated to cover the border of an element, it’s also been supported without prefixes by all major browsers since 2017. Something I hadn’t noticed before now is that border-image-sourceborder-image-source takes similar values to background-imagebackground-image, so instead of passing in an image as a URL you could pass a CSS gradient or a data URI, which is perfect for scalable border shapes.

border-image-sliceborder-image-slice defines how the source image is cut up into different regions – the corners are preserved as the corners, the edges are repeated or stretched as defined by the border-image-repeatborder-image-repeat property, and the central region is rendered under the element’s background with the keyword fillfill.

border-styleborder-style has to be set for the border image to work, so although you can set the width with border-image-widthborder-image-width I find it easier to use the borderborder shorthand to set the width and style together.

With gradients

I thought I could repurpose the gradient demo above to work as a border image but layering multiple gradients isn’t possible for border-image-sourceborder-image-source so there’s no way to do the cutout corners with this method, but of course I had a fun time playing around to see what effects I could get with one gradient.

Click an icon to reveal a code snippet and demo:

.polaroid {
  border: var(--corner) solid;
  border-image-source:
    radial-gradient(closest-side, transparent 70%, var(--bg) 70%);
  border-image-slice: 49%;
}
.polaroid {
  border: var(--corner) solid;
  border-image-source:
    radial-gradient(closest-side, transparent 70%, var(--bg) 70%);
  border-image-slice: 49%;
}
.disco {
  border: var(--corner) solid;
  border-image-source:
    radial-gradient(
      closest-side,
      transparent 10%, var(--bg) 10%,
      var(--bg) 20%, transparent 20%,
      transparent 30%, var(--bg) 30%,
      var(--bg) 40%, transparent 40%,
      transparent 50%, var(--bg) 50%,
      var(--bg) 60%, transparent 60%,
      transparent 70%, var(--bg) 70%,
      var(--bg) 80%, transparent 80%,
      transparent 90%, var(--bg) 99.99%,
      var(--bg) 100%, transparent 99.99%,
    );
  border-image-slice: 49%;
}
.disco {
  border: var(--corner) solid;
  border-image-source:
    radial-gradient(
      closest-side,
      transparent 10%, var(--bg) 10%,
      var(--bg) 20%, transparent 20%,
      transparent 30%, var(--bg) 30%,
      var(--bg) 40%, transparent 40%,
      transparent 50%, var(--bg) 50%,
      var(--bg) 60%, transparent 60%,
      transparent 70%, var(--bg) 70%,
      var(--bg) 80%, transparent 80%,
      transparent 90%, var(--bg) 99.99%,
      var(--bg) 100%, transparent 99.99%,
    );
  border-image-slice: 49%;
}
.warning {
  border: var(--corner) solid;
  border-image-source:
    repeating-linear-gradient(
      45deg,
      transparent 0px,
      transparent 4px,
      var(--bg)   4px,
      var(--bg)   8px,
      transparent 8px,
    );
  border-image-repeat: stretch;
  /* no idea how slicing works on a repeated gradient, but this seems to be okay? */
  border-image-slice: 35;
}
.warning {
  border: var(--corner) solid;
  border-image-source:
    repeating-linear-gradient(
      45deg,
      transparent 0px,
      transparent 4px,
      var(--bg)   4px,
      var(--bg)   8px,
      transparent 8px,
    );
  border-image-repeat: stretch;
  /* no idea how slicing works on a repeated gradient, but this seems to be okay? */
  border-image-slice: 35;
}
.bevel {
  border: var(--corner) solid;
  border-image-source:
    linear-gradient(
      to bottom right,
      transparent 20%,
      var(--bg)   20%,
      var(--bg)   80%,
      transparent 80%
    );
  border-image-slice: 40% fill;
}
.bevel {
  border: var(--corner) solid;
  border-image-source:
    linear-gradient(
      to bottom right,
      transparent 20%,
      var(--bg)   20%,
      var(--bg)   80%,
      transparent 80%
    );
  border-image-slice: 40% fill;
}
.brackets {
  border: var(--corner) solid;
  border-image-source:
    conic-gradient(
      transparent 10%,
      var(--bg)   10%,
      var(--bg)   40%,
      transparent 40%,
      transparent 60%,
      var(--bg)   60%,
      var(--bg)   90%,
      transparent 90%
    );
  /* browsers have different thresholds for this, Safari needs at least 0.1% */
  border-image-slice: 0.1%;
}
.brackets {
  border: var(--corner) solid;
  border-image-source:
    conic-gradient(
      transparent 10%,
      var(--bg)   10%,
      var(--bg)   40%,
      transparent 40%,
      transparent 60%,
      var(--bg)   60%,
      var(--bg)   90%,
      transparent 90%
    );
  /* browsers have different thresholds for this, Safari needs at least 0.1% */
  border-image-slice: 0.1%;
}

With SVG data URIs

Back to the scallop problem, let’s try using a border image with an embedded SVG. To keep the image slicing simple we’re going to use a unit grid where the SVG’s size is 3×3.

I drew this shape in Figma, exported it as an SVG and removed the chaff with SVGO, making sure to uncheck ‘prefer viewBox to width/height’ because the image needs to have an absolute size to be used in this way. To turn it into a data URI we wrap the SVG in a url function and specify the type and charset.

url('data:image/svg+xml;charset=utf-8,<svg>...</svg>');
url('data:image/svg+xml;charset=utf-8,<svg>...</svg>');

The downside of this method is that the colour has to be hard-coded into the SVG so we can’t control it with a CSS variable, but the upside is that it can be semi-transparent (N.B. Safari can have glitchy edges on translucent border images that use the fillfill keyword).

.frame {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="hsla(162deg, 59%, 62%, 0.5)"><path d="M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 01-1-1z"/></svg>');
  border-image-slice: 1 fill;
}
.frame {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="hsla(162deg, 59%, 62%, 0.5)"><path d="M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 01-1-1z"/></svg>');
  border-image-slice: 1 fill;
}

This technique can be extended to any frame design and size, even those with repeated edges. I got a bit carried away making examples:

.bevel {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="black"><path d="M1 0 0 1v1l1 1h1l1-1V1L2 0H1Z"/></svg>');
  border-image-slice: 1 fill;
}
.bevel {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="black"><path d="M1 0 0 1v1l1 1h1l1-1V1L2 0H1Z"/></svg>');
  border-image-slice: 1 fill;
}
.scallop {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="black"><path d="M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 0 1-1-1Z"/></svg>');
  border-image-slice: 1 fill;
}
.scallop {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="black"><path d="M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 0 1-1-1Z"/></svg>');
  border-image-slice: 1 fill;
}
.notch {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="black"><path d="M1 1H0v1h1v1h1V2h1V1H2V0H1v1Z"/></svg>');
  border-image-slice: 1 fill;
}
.notch {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3" fill="black"><path d="M1 1H0v1h1v1h1V2h1V1H2V0H1v1Z"/></svg>');
  border-image-slice: 1 fill;
}
.column {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="M3 0h1v1a2 2 0 0 1 2 2h1v1H6a2 2 0 0 1-2 2v1H3V6a2 2 0 0 1-2-2H0V3h1c0-1.1.9-2 2-2V0Z" /></svg>');
  border-image-slice: 3 fill;
}
.column {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="M3 0h1v1a2 2 0 0 1 2 2h1v1H6a2 2 0 0 1-2 2v1H3V6a2 2 0 0 1-2-2H0V3h1c0-1.1.9-2 2-2V0Z" /></svg>');
  border-image-slice: 3 fill;
}
.dogear {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="black"><path d="M4 0h1l1 1h2v2l1 1v1L8 6v2H6L5 9H4L3 8H1V6L0 5V4l1-1V1h2l1-1Z" /></svg>');
  border-image-slice: 4 fill;
}
.dogear {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="black"><path d="M4 0h1l1 1h2v2l1 1v1L8 6v2H6L5 9H4L3 8H1V6L0 5V4l1-1V1h2l1-1Z" /></svg>');
  border-image-slice: 4 fill;
}
.bubble {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="m3 0-.8.8a1 1 0 0 0-1.7.7c0 .3.1.5.3.7L0 3v1l.8.8a1 1 0 0 0 .7 1.7c.3 0 .5-.1.7-.3L3 7h1l.8-.8a1 1 0 0 0 1.7-.7 1 1 0 0 0-.3-.7L7 4V3l-.8-.8A1 1 0 0 0 5.5.5a1 1 0 0 0-.7.3L4 0H3Z" /></svg>');
  border-image-slice: 3 fill;
}
.bubble {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="m3 0-.8.8a1 1 0 0 0-1.7.7c0 .3.1.5.3.7L0 3v1l.8.8a1 1 0 0 0 .7 1.7c.3 0 .5-.1.7-.3L3 7h1l.8-.8a1 1 0 0 0 1.7-.7 1 1 0 0 0-.3-.7L7 4V3l-.8-.8A1 1 0 0 0 5.5.5a1 1 0 0 0-.7.3L4 0H3Z" /></svg>');
  border-image-slice: 3 fill;
}
.spike {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="black"><path d="M4 0h1l1 2 1.5-.5L7 3l2 1v1L7 6l.5 1.5L6 7 5 9H4L3 7l-1.5.5L2 6 0 5V4l2-1-.5-1.5L3 2l1-2Z" /></svg>');
  border-image-slice: 4 fill;
}
.spike {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="black"><path d="M4 0h1l1 2 1.5-.5L7 3l2 1v1L7 6l.5 1.5L6 7 5 9H4L3 7l-1.5.5L2 6 0 5V4l2-1-.5-1.5L3 2l1-2Z" /></svg>');
  border-image-slice: 4 fill;
}
.roman {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="M3 0h1a.5.5 0 0 1 0 1c0 1.1.9 2 2 2a.5.5 0 0 1 1 0v1a.5.5 0 0 1-1 0 2 2 0 0 0-2 2 .5.5 0 0 1 0 1H3a.5.5 0 0 1 0-1 2 2 0 0 0-2-2 .5.5 0 0 1-1 0V3a.5.5 0 0 1 1 0 2 2 0 0 0 2-2 .5.5 0 0 1 0-1Z" /></svg>');
  border-image-slice: 3 fill;
}
.roman {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="M3 0h1a.5.5 0 0 1 0 1c0 1.1.9 2 2 2a.5.5 0 0 1 1 0v1a.5.5 0 0 1-1 0 2 2 0 0 0-2 2 .5.5 0 0 1 0 1H3a.5.5 0 0 1 0-1 2 2 0 0 0-2-2 .5.5 0 0 1-1 0V3a.5.5 0 0 1 1 0 2 2 0 0 0 2-2 .5.5 0 0 1 0-1Z" /></svg>');
  border-image-slice: 3 fill;
}
.squeeze {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" fill="black"><path d="M1 4H0c.3-.3.5-1.1.5-1.5C.5 2.1.3 1.3 0 1h1V0c.3.3 1.1.5 1.5.5C2.9.5 3.7.3 4 0v1h1c-.3.3-.5 1.1-.5 1.5 0 .4.2 1.2.5 1.5H4v1c-.3-.3-1.1-.5-1.5-.5-.4 0-1.2.2-1.5.5V4Z" /></svg>');
  border-image-slice: 1 fill;
}
.squeeze {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" fill="black"><path d="M1 4H0c.3-.3.5-1.1.5-1.5C.5 2.1.3 1.3 0 1h1V0c.3.3 1.1.5 1.5.5C2.9.5 3.7.3 4 0v1h1c-.3.3-.5 1.1-.5 1.5 0 .4.2 1.2.5 1.5H4v1c-.3-.3-1.1-.5-1.5-.5-.4 0-1.2.2-1.5.5V4Z" /></svg>');
  border-image-slice: 1 fill;
}
.quatrefoil {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" fill="black"><path d="M3 0C3.55228 0 4 0.447715 4 1C4.55228 1 5 1.44772 5 2V3C5 3.55228 4.55228 4 4 4C4 4.55228 3.55228 5 3 5H2C1.44772 5 1 4.55228 1 4C0.447715 4 0 3.55228 0 3V2C0 1.44772 0.447715 1 1 1C1 0.447715 1.44772 0 2 0H3Z"/></svg>');
  border-image-slice: 2 fill;
}
.quatrefoil {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" fill="black"><path d="M3 0C3.55228 0 4 0.447715 4 1C4.55228 1 5 1.44772 5 2V3C5 3.55228 4.55228 4 4 4C4 4.55228 3.55228 5 3 5H2C1.44772 5 1 4.55228 1 4C0.447715 4 0 3.55228 0 3V2C0 1.44772 0.447715 1 1 1C1 0.447715 1.44772 0 2 0H3Z"/></svg>');
  border-image-slice: 2 fill;
}
.wavy {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="M0 1c0-.552285.447715-1 1-1 .5 0 .875.25 1.25.5S3 1 3.5 1 4.375.75 4.75.5 5.5 0 6 0c.55228 0 1 .447715 1 1 0 .5-.25.875-.5 1.25S6 3 6 3.5s.25.875.5 1.25S7 5.5 7 6c0 .5333-.41747.9691-.94344.99843-.46617-.0215-.82387-.25997-1.18156-.49843-.375-.25-.75-.5-1.25-.5s-.875.25-1.25.5-.75.5-1.25.5H1c-.533303 0-.9691-.41747-.998427-.94344C.023075 5.59039.261537 5.23269.5 4.875c.25-.375.5-.75.5-1.25S.75 2.75.5 2.375s-.5-.75-.5-1.25V1Z" /></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.wavy {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" fill="black"><path d="M0 1c0-.552285.447715-1 1-1 .5 0 .875.25 1.25.5S3 1 3.5 1 4.375.75 4.75.5 5.5 0 6 0c.55228 0 1 .447715 1 1 0 .5-.25.875-.5 1.25S6 3 6 3.5s.25.875.5 1.25S7 5.5 7 6c0 .5333-.41747.9691-.94344.99843-.46617-.0215-.82387-.25997-1.18156-.49843-.375-.25-.75-.5-1.25-.5s-.875.25-1.25.5-.75.5-1.25.5H1c-.533303 0-.9691-.41747-.998427-.94344C.023075 5.59039.261537 5.23269.5 4.875c.25-.375.5-.75.5-1.25S.75 2.75.5 2.375s-.5-.75-.5-1.25V1Z" /></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.stamp {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" fill="black"><path d="M1 2.5c0 .38-.25.56-.5.75-.25.19-.5.38-.5.75a1 1 0 0 0 1 1c.38 0 .56-.25.75-.5.19-.25.38-.5.75-.5.38 0 .56.25.75.5.19.25.38.5.75.5a1 1 0 0 0 1-1c0-.38-.25-.56-.5-.75-.25-.19-.5-.38-.5-.75 0-.38.25-.56.5-.75.25-.19.5-.38.5-.75a1 1 0 0 0-1-1c-.38 0-.56.25-.75.5-.19.25-.37.5-.75.5S1.94.75 1.75.5C1.56.25 1.37 0 1 0a1 1 0 0 0-1 1c0 .38.25.56.5.75.25.19.5.37.5.75Z" /></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.stamp {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" fill="black"><path d="M1 2.5c0 .38-.25.56-.5.75-.25.19-.5.38-.5.75a1 1 0 0 0 1 1c.38 0 .56-.25.75-.5.19-.25.38-.5.75-.5.38 0 .56.25.75.5.19.25.38.5.75.5a1 1 0 0 0 1-1c0-.38-.25-.56-.5-.75-.25-.19-.5-.38-.5-.75 0-.38.25-.56.5-.75.25-.19.5-.38.5-.75a1 1 0 0 0-1-1c-.38 0-.56.25-.75.5-.19.25-.37.5-.75.5S1.94.75 1.75.5C1.56.25 1.37 0 1 0a1 1 0 0 0-1 1c0 .38.25.56.5.75.25.19.5.37.5.75Z" /></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.cloud {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" fill="black"><path d="M1 2c-.552285 0-1 .44772-1 1s.447715 1 1 1c.55228 0 1-.44772 1-1 0 .55228.44772 1 1 1s1-.44772 1-1-.44772-1-1-1c.55228 0 1-.44772 1-1 0-.552285-.44772-1-1-1S2 .447715 2 1c0-.552285-.44772-1-1-1-.552285 0-1 .447715-1 1 0 .55228.447715 1 1 1Z"/></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.cloud {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" fill="black"><path d="M1 2c-.552285 0-1 .44772-1 1s.447715 1 1 1c.55228 0 1-.44772 1-1 0 .55228.44772 1 1 1s1-.44772 1-1-.44772-1-1-1c.55228 0 1-.44772 1-1 0-.552285-.44772-1-1-1S2 .447715 2 1c0-.552285-.44772-1-1-1-.552285 0-1 .447715-1 1 0 .55228.447715 1 1 1Z"/></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.zigzag {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" fill="black"><path d="M2 1 1 0 0 1l1 1-1 1 1 1 1-1 1 1 1-1-1-1 1-1-1-1-1 1Z" /></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.zigzag {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" fill="black"><path d="M2 1 1 0 0 1l1 1-1 1 1 1 1-1 1 1 1-1-1-1 1-1-1-1-1 1Z" /></svg>');
  border-image-slice: 1 fill;
  border-image-repeat: round;
}
.diamonds {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="black"><path fill-rule="evenodd" d="M1 .5.5 0 0 .5l.5.49.5-.5Zm0 1L.5 1l-.5.5.5.49.5-.5ZM.5 2l.5.5-.5.49-.5-.5L.5 2Zm1.49.5-.5-.5-.49.5.5.49.49-.5Zm.5-.5.5.5-.5.49-.49-.5.5-.49Zm.5-.5-.5-.5-.49.5.5.49.49-.5ZM2.49 0l.5.5-.5.49L2 .49 2.5 0ZM2 .5 1.5 0 1 .5l.5.49.49-.5Z" /></svg>');
  border-image-slice: 1;
  border-image-repeat: round;
}
.diamonds {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="black"><path fill-rule="evenodd" d="M1 .5.5 0 0 .5l.5.49.5-.5Zm0 1L.5 1l-.5.5.5.49.5-.5ZM.5 2l.5.5-.5.49-.5-.5L.5 2Zm1.49.5-.5-.5-.49.5.5.49.49-.5Zm.5-.5.5.5-.5.49-.49-.5.5-.49Zm.5-.5-.5-.5-.49.5.5.49.49-.5ZM2.49 0l.5.5-.5.49L2 .49 2.5 0ZM2 .5 1.5 0 1 .5l.5.49.49-.5Z" /></svg>');
  border-image-slice: 1;
  border-image-repeat: round;
}
.square {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="black"><path fill-rule="evenodd" d="M0 0h1v1H0V0Zm2 1H1v1H0v1h1V2h1v1h1V2H2V1Zm0 0V0h1v1H2Z" /></svg>');
  border-image-slice: 1 fill;
}
.square {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="black"><path fill-rule="evenodd" d="M0 0h1v1H0V0Zm2 1H1v1H0v1h1V2h1v1h1V2H2V1Zm0 0V0h1v1H2Z" /></svg>');
  border-image-slice: 1 fill;
}
.mosaic {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" fill="black"><path d="M0 0h2v2H0zm0 3h2v2H0zm0 3h2v2H0zm6 0h2v2H6zm0-3h2v2H6zM3 6h2v2H3zm0-6h2v2H3zm3 0h2v2H6z" /></svg>');
  border-image-slice: 3;
}
.mosaic {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" fill="black"><path d="M0 0h2v2H0zm0 3h2v2H0zm0 3h2v2H0zm6 0h2v2H6zm0-3h2v2H6zM3 6h2v2H3zm0-6h2v2H3zm3 0h2v2H6z" /></svg>');
  border-image-slice: 3;
}
.fringe {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" fill="black"><path d="M2 0h2v2h4v2H6v4H4V6H0V4h2V0Z" /></svg>');
  border-image-slice: 2;
  border-image-repeat: space;
  /* because the slices are repeated with spaces between them the content isn't fully covered, we we need to use a background here */
  background: var(--bg);
  background-clip: padding-box;
}
.fringe {
  border: var(--corner) solid;
  border-image-source: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" fill="black"><path d="M2 0h2v2h4v2H6v4H4V6H0V4h2V0Z" /></svg>');
  border-image-slice: 2;
  border-image-repeat: space;
  /* because the slices are repeated with spaces between them the content isn't fully covered, we we need to use a background here */
  background: var(--bg);
  background-clip: padding-box;
}

It’s worth noting that this method doesn’t restrict you to one colour, you can drop in any kind of SVG design as long as it’s sliceable!

Some caution is needed for decorative edges though – browsers treat the border-image-repeatborder-image-repeat property inconsistently; on Safari with roundround or spacespace and on Chrome with spacespace the edge doesn’t maintain its aspect ratio when it’s repeated which can mess up the edge effects.

CSS border masks

The technique above does reliably make fancy corners, but since they’re decorative borders we can’t use them to mask out actual content, and in a dream world I wanted to use this technique on images as well.

Behold, mask-bordermask-border, which has the same constituent properties as border-imageborder-image but masks out the content instead of adding a border. The problem? Firefox never got around to implementing it 😞 Safari implemented it in 2008 and Edge didn’t until 2020, so there’s still hope it will come to Firefox at some point.

Usually a major browser lacking implementation would block me from using a CSS feature, but since this is a visual flourish and will have no effect if it doesn’t work (users will see a rectangle instead of a fancy rectangle) I think it’s an acceptable progressive enhancement.

By default mask-bordermask-border uses the alpha channel of the source to determine the shape to mask, so we can use the same code as our border-image-sourceborder-image-source but set the colour to black.

.frame {
  mask-border-source: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' width='3' height='3' fill='black'><path d='M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 01-1-1z'/></svg>");
  mask-border-slice: 1 fill;
  mask-border-width: var(--corner);
}
.frame {
  mask-border-source: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' width='3' height='3' fill='black'><path d='M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 01-1-1z'/></svg>");
  mask-border-slice: 1 fill;
  mask-border-width: var(--corner);
}

CSS Houdini Paint API

The CSS Paint API is made exactly for these kinds of decorative use cases, but without Firefox and widespread Safari support it’s not viable for use in production at the moment without a polyfill.

The gist is that we can use a 2D rendering context (similar to the Canvas API) to draw background images, masks, and borders from within a CSS declaration. As a generative artist, the Canvas API is my bread and butter, it is so cool to be able to combine it with CSS. I feel like Captain Planet.

The first step is to create a JavaScript file which initiates a PaintWorkletPaintWorklet. This worklet draws a black rectangle with scalloped cutouts; their size is specified by the --corner--corner CSS variable:

worklet.js
registerPaint(
  'scallop',
  class {
    static get inputProperties() {
      return ['--corner']
    }
 
    paint(c, size, styleMap) {
      const corner = parseInt(styleMap.get('--corner').toString())
 
      c.fillStyle = '#000'
      c.beginPath()
      // top left
      c.arc(0, 0, corner, 0, Math.PI / 2)
      // bottom left
      c.arc(0, size.height, corner, -Math.PI / 2, 0)
      // bottom right
      c.arc(size.width, size.height, corner, -Math.PI, -Math.PI / 2)
      // top right
      c.arc(size.width, 0, corner, Math.PI / 2, Math.PI)
      c.closePath()
      c.fill()
    }
  }
)
worklet.js
registerPaint(
  'scallop',
  class {
    static get inputProperties() {
      return ['--corner']
    }
 
    paint(c, size, styleMap) {
      const corner = parseInt(styleMap.get('--corner').toString())
 
      c.fillStyle = '#000'
      c.beginPath()
      // top left
      c.arc(0, 0, corner, 0, Math.PI / 2)
      // bottom left
      c.arc(0, size.height, corner, -Math.PI / 2, 0)
      // bottom right
      c.arc(size.width, size.height, corner, -Math.PI, -Math.PI / 2)
      // top right
      c.arc(size.width, 0, corner, Math.PI / 2, Math.PI)
      c.closePath()
      c.fill()
    }
  }
)

We register the worklet in our page’s JavaScript:

CSS.paintWorklet.addModule('worklet.js')
CSS.paintWorklet.addModule('worklet.js')

And then we can call the worklet in our CSS. The paintpaint function can be applied to anywhere a CSS <image> type is used so for this worklet I’m going to apply it to the mask-imagemask-image property, that way we don’t have to pass in the background colour and can use it on any type of element (e.g. an image or on an element with a gradient background).

.frame {
  --corner: 20px;
  mask-image: paint(scallop);
  background: teal;
}
.frame {
  --corner: 20px;
  mask-image: paint(scallop);
  background: teal;
}

You can see a live demo of this example here.

By specifying inputPropertiesinputProperties we’re not only telling the worklet which CSS variables we want to have access to, but also to keep an eye on them; whenever these variables change the image will re-paint. This can be really powerful when combined with interactions and animations – css-houdini.rocks and houdini.how have a bunch of Houdini examples if you want to explore this technology more.

Fluid scaling

The CSS Typed OM which models the paintpaint function’s styleMapstyleMap argument currently treats all properties as string values which is why we have to specify the --corner--corner length in pixels. All my spacing is fluid so pixel values aren’t going to work for me, but I have a workaround with our old friend border-imageborder-image. First we need to modify the worklet to take a --bg--bg variable and set that as the fillStylefillStyle (I’ll let you figure that out) and then we can use it like this:

.frame {
  --corner: 10;
  --bg: cadetblue;
  border: 1em solid;
  border-image-source: paint(scallop);
  border-image-slice: var(--corner) fill;
}
.frame {
  --corner: 10;
  --bg: cadetblue;
  border: 1em solid;
  border-image-source: paint(scallop);
  border-image-slice: var(--corner) fill;
}

What we’re doing here is drawing the background with a 10px cutout, slicing it up with 10px corners, and then growing or shrinking the corners and edges to fit our actual border width.

We lose the ability to mask out images and gradient backgrounds but gain the ability to use any length we want for the border width, including percentages and values that use CSS functions such as calccalc. Hopefully in time we’ll be able to parse different units within worklets so this sort of hack isn’t necessary.

SVG-in-JS-in-CSS

You may have thought that we were at the final step of the galaxy brain meme, but I’m afraid I can’t and won’t stop.

Inspired by Vincent De Oliveira’s demo I wondered if we could reuse our SVG paths from the border-imageborder-image saga but be able to set our background colour as a CSS variable by leveraging the Paint API.

worklet.js
registerPaint(
  'svg-border-image',
  class {
    static get inputProperties() {
      return ['--bg', '--svg-path', '--svg-size']
    }
 
    paint(c, size, styleMap) {
      const bg = styleMap.get('--bg').toString()
      const svgPath = styleMap
        .get('--svg-path')
        .toString()
        .replaceAll(/["']+/g, '') // remove wrapping quotes
      const svgSize = parseInt(styleMap.get('--svg-size').toString())
 
      const path2D = new Path2D(svgPath)
 
      c.scale(size.width / svgSize, size.height / svgSize)
      c.fillStyle = bg
      c.fill(path2D)
    }
  }
)
worklet.js
registerPaint(
  'svg-border-image',
  class {
    static get inputProperties() {
      return ['--bg', '--svg-path', '--svg-size']
    }
 
    paint(c, size, styleMap) {
      const bg = styleMap.get('--bg').toString()
      const svgPath = styleMap
        .get('--svg-path')
        .toString()
        .replaceAll(/["']+/g, '') // remove wrapping quotes
      const svgSize = parseInt(styleMap.get('--svg-size').toString())
 
      const path2D = new Path2D(svgPath)
 
      c.scale(size.width / svgSize, size.height / svgSize)
      c.fillStyle = bg
      c.fill(path2D)
    }
  }
)

This draws the --svg-path--svg-path with Canvas methods, setting the shape fill colour to our --bg--bg CSS variable and scaling up the path to the full size of the box. Then we can slice it as a percentage of the SVG width and height.

.frame {
  --bg: lightseagreen;
  --svg-path: 'M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 0 1-1-1Z';
  --svg-size: 3;
  --svg-slice: 1;
  border: 1em solid;
  border-image-source: paint(svg-border-image);
  border-image-slice: calc(100% * var(--svg-slice) / var(--svg-size)) fill;
}
.frame {
  --bg: lightseagreen;
  --svg-path: 'M2 0H1C1 .6.6 1 0 1v1c.6 0 1 .4 1 1h1c0-.6.4-1 1-1V1a1 1 0 0 1-1-1Z';
  --svg-size: 3;
  --svg-slice: 1;
  border: 1em solid;
  border-image-source: paint(svg-border-image);
  border-image-slice: calc(100% * var(--svg-slice) / var(--svg-size)) fill;
}

You can see a live demo of this method here.

This does work very effectively but I’m not a big fan of having to store the path data in CSS. Ideally, we’d be able to embed all our SVG data in the worklet and call it like this:

.frame {
  border: 1em solid;
  border-image: paint(border-image, scallop);
}
.frame {
  border: 1em solid;
  border-image: paint(border-image, scallop);
}

Unfortunately, this doesn’t work because the paint function can only be used in place of <image> values and we’d also need to set the border-image-sliceborder-image-slice based on the SVG data. We could reconfigure the paths to all have the same slice value (e.g. 10%10%) but regardless we’d have to set border-image-sliceborder-image-slice property wherever we use the worklet which sort of defeats the elegance of the whole thing.

Final thoughts

There are a lot more methods of achieving this effect than I expected when I first started thinking about this, and they all have their drawbacks:

I am so excited by the possibilities presented by the CSS Paint API, this was my first foray into using Houdini and I think we’re going to be good friends in time.

However, I don’t want to use a polyfill – bloating my JavaScript bundle for this decorative effect feels icky, and I don’t trust polyfills when there are so many edge cases*, so for now I’m sticking with the SVG data URI method across this website.

One thing to note is that I haven’t done any rendering performance testing of the different methods because I’m not using them on a very large scale, maybe that will be the cinch on which one is right for you.

I hope you’ve enjoyed my first blog post! I think I went too big too early, what do you think? As you can tell I am a big fan of CSS so follow along for more of that hot CSS content.