Say we want to target an element and just visually blur the border of it. There is no simple, single built-in web platform feature we can reach for. But we can get it done with a little CSS trickery.
Here’s what we’re after:
Let’s see how we can code this effect, how we can enhance it with rounded corners, extend support so it works cross-browser, what the future will bring in this department and what other interesting results we can get starting from the same idea!
Coding the basic blurred border
We start with an element on which we set some dummy dimensions, a partially transparent
(just slightly visible) border
and a background
whose size is relative to the border-box
, but whose visibility we restrict to the padding-box
:
$b: 1.5em; // border-width
div {
border: solid $b rgba(#000, .2);
height: 50vmin;
max-width: 13em;
max-height: 7em;
background: url(oranges.jpg) 50%/ cover
border-box /* background-origin */
padding-box /* background-clip */;
}
The box specified by background-origin
is the box whose top left corner is the 0 0
point for background-position
and also the box that background-size
(set to cover
in our case) is relative to. The box specified by background-clip
is the box within whose limits the background
is visible.
The initial values are padding-box
for background-origin
and border-box
for background-clip
, so we need to specify them both in this case.
If you need a more in-depth refresher on background-origin
and background-clip
, you can check out this detailed article on the topic.
The code above gives us the following result:
See the Pen by thebabydino (@thebabydino) on CodePen.
Next, we add an absolutely positioned pseudo-element that covers its entire parent’s border-box
and is positioned behind (z-index: -1
). We also make this pseudo-element inherit its parent’s border
and background
, then we change the border-color
to transparent
and the background-clip
to border-box
:
$b: 1.5em; // border-width
div {
position: relative;
/* same styles as before */
&:before {
position: absolute;
z-index: -1;
/* go outside padding-box by
* a border-width ($b) in every direction */
top: -$b; right: -$b; bottom: -$b; left: -$b;
border: inherit;
border-color: transparent;
background: inherit;
background-clip: border-box;
content: ''
}
}
Now we can also see the background
behind the barely visible border
:
See the Pen by thebabydino (@thebabydino) on CodePen.
Alright, you may be seeing already where this is going! The next step is to blur()
the pseudo-element. Since this pseudo-element is only visible only underneath the partially transparent border
(the rest is covered by its parent’s padding-box
-restricted background
), it results the border
area is the only area of the image we see blurred.
See the Pen by thebabydino (@thebabydino) on CodePen.
We’ve also brought the alpha of the element’s border-color
down to .03
because we want the blurriness to be doing most of the job of highlighting where the border
is.
This may look done, but there’s something I still don’t like: the edges of the pseudo-element are now blurred as well. So let’s fix that!
One convenient thing when it comes to the order browsers apply properties in is that filters are applied before clipping. While this is not what we want and makes us resort to inconvenient workarounds in a lot of other cases… right here, it proves to be really useful!
It means that, after blurring the pseudo-element, we can clip it to its border-box
!
My preferred way of doing this is by setting clip-path
to inset(0)
because… it’s the simplest way of doing it, really! polygon(0 0, 100% 0, 100% 100%, 0 100%)
would be overkill.
See the Pen by thebabydino (@thebabydino) on CodePen.
In case you’re wondering why not set the clip-path
on the actual element instead of setting it on the :before
pseudo-element, this is because setting clip-path
on the element would make it a stacking context. This would force all its child elements (and consequently, its blurred :before
pseudo-element as well) to be contained within it and, therefore, in front of its background
. And then no nuclear z-index
or !important
could change that.
We can prettify this by adding some text with a nicer font
, a box-shadow
and some layout properties.
What if we have rounded corners?
The best thing about using inset()
instead of polygon()
for the clip-path
is that inset()
can also accommodate for any border-radius
we may want!
And when I say any border-radius
, I mean it! Check this out!
div {
--r: 15% 75px 35vh 13vw/ 3em 5rem 29vmin 12.5vmax;
border-radius: var(--r);
/* same styles as before */
&:before {
/* same styles as before */
border-radius: inherit;
clip-path: inset(0 round var(--r));
}
}
It works like a charm!
See the Pen by thebabydino (@thebabydino) on CodePen.
Extending support
Some mobile browsers still need the -webkit-
prefix for both filter
and clip-path
, so be sure to include those versions too. Note that they are included in the CodePen demos embeded here, even though I chose to skip them in the code presented in the body of this article.
Alright, but what if we need to support Edge? clip-path
doesn’t work in Edge, but filter
does, which means we do get the blurred border, but no sharp cut limits.
Well, if we don’t need corner rounding, we can use the deprecated clip
property as a fallback. This means adding the following line right before the clip-path
ones:
clip: rect(0 100% 100% 0)
And our demo now works in Edge… sort of! The right, bottom and left edges are cut sharply, but the top one still remains blurred (only in the Debug mode of the Pen, all seems fine for the iframe in the Editor View). And opening DevTools or right clicking in the Edge window or clicking anywhere outside this window makes the effect of this property vanish. Bug of the month right there!
Alright, since this is so unreliable and it doesn’t even help us if we want rounded corners, let’s try another approach!
This is a bit like scratching behind the left ear with the right foot (or the other way around, depending on which side is your more flexible one), but it’s the only way I can think of to make it work in Edge.
Some of you may have already been screaming at the screen something like “but Ana… overflow: hidden
!” and yes, that’s what we’re going for now. I’ve avoided it initially because of the way it works: it cuts out all descendant content outside the padding-box
. Not outside the border-box
, as we’ve done by clipping!
This means we need to ditch the real border
and emulate it with padding
, which I’m not exactly delighted about because it can lead to more complications, but let’s take it one step at a time!
As far as code changes are concerned, the first thing we do is remove all border
-related properties and set the border-width
value as the padding
. We then set overflow: hidden
and restrict the background
of the actual element to the content-box
. Finally, we reset the pseudo-element’s background-clip
to the padding-box
value and zero its offsets.
$fake-b: 1.5em; // fake border-width
div {
/* same styles as before */
overflow: hidden;
padding: $fake-b;
background: url(oranges.jpg) 50%/ cover
padding-box /* background-origin */
content-box /* background-clip */;
&:before {
/* same styles as before */
top: 0; right: 0; bottom: 0; left: 0;
background: inherit;
background-clip: padding-box;
}
}
See the Pen by thebabydino (@thebabydino) on CodePen.
If we want that barely visible “border” overlay, we need another background
layer on the actual element:
$fake-b: 1.5em; // fake border-width
$c: rgba(#000, .03);
div {
/* same styles as before */
overflow: hidden;
padding: $fake-b;
--img: url(oranges.jpg) 50%/ cover;
background: var(--img)
padding-box /* background-origin */
content-box /* background-clip */,
linear-gradient($c, $c);
&:before {
/* same styles as before */
top: 0; right: 0; bottom: 0; left: 0;
background: var(--img);
}
}
See the Pen by thebabydino (@thebabydino) on CodePen.
We can also add rounded corners with no hassle:
See the Pen by thebabydino (@thebabydino) on CodePen.
So why didn’t we do this from the very beginning?!
Remember when I said a bit earlier that not using an actual border
can complicate things later on?
Well, let’s say we want to have some text. With the first method, using an actual border
and clip-path
, all it takes to prevent the text content from touching the blurred border
is adding a padding
(of let’s say 1em
) on our element.
See the Pen by thebabydino (@thebabydino) on CodePen.
But with the overflow: hidden
method, we’ve already used the padding
property to create the blurred “border”. Increasing its value doesn’t help because it only increases the fake border’s width.
We could add the text into a child element. Or we could also use the :after
pseudo-element!
The way this works is pretty similar to the first method, with the :after
replacing the actual element. The difference is we clip the blurred edges with overflow: hidden
instead of clip-path: inset(0)
and the padding
on the actual element is the pseudos’ border-width
($b
) plus whatever padding
value we want:
$b: 1.5em; // border-width
div {
overflow: hidden;
position: relative;
padding: calc(1em + #{$b});
/* prettifying styles */
&:before, &:after {
position: absolute;
z-index: -1; /* put them *behind* parent */
/* zero all offsets */
top: 0; right: 0; bottom: 0; left: 0;
border: solid $b rgba(#000, .03);
background: url(oranges.jpg) 50%/ cover
border-box /* background-origin */
padding-box /* background-clip */;
content: ''
}
&:before {
border-color: transparent;
background-clip: border-box;
filter: blur(9px);
}
}
See the Pen by thebabydino (@thebabydino) on CodePen.
What about having both text and some pretty extreme rounded corners? Well, that’s something we’ll discuss in another article – stay tuned!
backdrop-filter
?
What about Some of you may be wondering (as I was when I started toying with various ideas in order to try to achieve this effect) whether backdrop-filter
isn’t an option.
Well, yes and no!
Technically, it is possible to get the same effect, but since Firefox doesn’t yet implement it, we’re cutting out Firefox support if we choose to take this route. Not to mention this approach also forces us to use both pseudo-elements if we want the best support possible for the case when our element has some text content (which means we need the pseudos and their padding-box
area background
to show underneath this text).
Update: due to a regression, the backdrop-filter
technique doesn’t work in Chrome anymore, so support is now limited to Safari and Edge at best.
For those who don’t yet know what backdrop-filter
does: it filters out what can be seen through the (partially) transparent
parts of the element we apply it on.
The way we need to go about this is the following: both pseudo-elements have a transparent border
and a background
positioned and sized relative to the padding-box
. We restrict the background
of pseudo-element on top (the :after
) to the padding-box
.
Now the :after
doesn’t have a background
in the border
area anymore and we can see through to the :before
pseudo-element behind it there. We set a backdrop-filter
on the :after
and maybe even change that border-color
from transparent
to slightly visible. The bottom (:before
) pseudo-element’s background
that’s still visible through the (partially) transparent
, barely distinguishable border
of the :after
above gets blurred as a result of applying the backdrop-filter
.
$b: 1.5em; // border-width
div {
overflow: hidden;
position: relative;
padding: calc(1em + #{$b});
/* prettifying styles */
&:before, &:after {
position: absolute;
z-index: -1; /* put them *behind* parent */
/* zero all offsets */
top: 0; right: 0; bottom: 0; left: 0;
border: solid $b transparent;
background: $url 50%/ cover
/* background-origin & -clip */
border-box;
content: ''
}
&:after {
border-color: rgba(#000, .03);
background-clip: padding-box;
backdrop-filter: blur(9px); /* no Firefox support */
}
}
Remember that the live demo for this doesn’t currently work in Firefox and needs the Experimental Web Platform features flag enabled in chrome://flags
in order to work in Chrome.
Eliminating one pseudo-element
This is something I wouldn’t recommend doing in the wild because it cuts out Edge support as well, but we do have a way of achieving the result we want with just one pseudo-element.
We start by setting the image background on the element (we don’t really need to explicitly set a border
as long as we include its width in the padding
) and then a partially transparent
, barely visible background
on the absolutely positioned pseudo-element that’s covering its entire parent. We also set the backdrop-filter
on this pseudo-element.
$b: 1.5em; // border-width
div {
position: relative;
padding: calc(1em + #{$b});
background: url(oranges.jpg) 50%/ cover;
/* prettifying styles */
&:before {
position: absolute;
/* zero all offsets */
top: 0; right: 0; bottom: 0; left: 0;
background: rgba(#000, .03);
backdrop-filter: blur(9px); /* no Firefox support */
content: ''
}
}
Alright, but this blurs out the entire element behind the almost transparent
pseudo-element, including its text. And it’s no bug, this is what backdrop-filter
is supposed to do.
In order to fix this, we need to get rid of (not make transparent
, that’s completely useless in this case) the inner rectangle (whose edges are a distance $b
away from the border-box
edges) of the pseudo-element.
We have two ways of doing this.
The first way (live demo) is with clip-path
and the zero-width tunnel technique:
$b: 1.5em; // border-width
$o: calc(100% - #{$b});
div {
/* same styles as before */
&:before {
/* same styles as before */
/* doesn't work in Edge */
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%,
0 0,
#{$b $b}, #{$b $o}, #{$o $o}, #{$o $b},
#{$b $b});
}
}
The second way (live demo) is with two composited mask
layers (note that, in this case, we need to explicitly set a border
on our pseudo):
$b: 1.5em; // border-width
div {
/* same styles as before */
&:before {
/* same styles as before */
border: solid $b transparent;
/* doesn't work in Edge */
--fill: linear-gradient(red, red);
-webkit-mask: var(--fill) padding-box,
var(--fill);
-webkit-mask-composite: xor;
mask: var(--fill) padding-box exclude,
var(--fill);
}
}
Since neither of these two properties works in Edge, this means support is now limited to WebKit browsers (and we still need to enable the Experimental Web Platform features flag for backdrop-filter
to work in Chrome).
Future (and better!) solution
The filter()
function allows us to apply filters on individual background
layers. This eliminates the need for a pseudo-element and reduces the code needed to achieve this effect to two CSS declarations!
border: solid 1.5em rgba(#000, .03);
background: $url
border-box /* background-origin */
padding-box /* background-clip */,
filter($url, blur(9px))
/* background-origin & background-clip */
border-box
As you may have guessed, the issue here is support. Safari is the only browser to implement it at this point, but if you think the filter()
is something that could help you, you can add your use cases and track implementation progress for both Chrome and Firefox.
More border filter options
I’ve only talked about blurring the border
up to now, but this technique works for pretty much any CSS filter
(save for drop-shadow()
which wouldn’t make much sense in this context). You can play with switching between them and tweaking values in the interactive demo below:
See the Pen by thebabydino (@thebabydino) on CodePen.
And all we’ve done so far has used just one filter
function, but we can also chain them and then the possibilities are endless – what cool effects can you come up with this way?
See the Pen by thebabydino (@thebabydino) on CodePen.
Thank you for this another cool article. :)
Speaking of
backdrop-filter
, you can mimic it withelement()
to make it work in Firefox. Not an easy task, and with drawback, but somehow doable: http://iamvdo.me/en/blog/css-element-function#faking-backdrop-filterSpeaking of
filter()
function, it is already implemented since Safari 9: http://iamvdo.me/en/blog/advanced-css-filters#filterAnd can be easily polyfillable with Houdini (with some workaround for now as
<image>
type for custom properties is not supported yet): https://css-houdini.rocks/background-propertiesOh, that’s a really cool idea to use
element()
to get around Firefox not supportingbackdrop-filter
! And thanks for letting me know aboutfilter()
being already supported by Safari, I’ve now updated the article.I love the
filter()
function, wish more browsers would implement it!Safari has had support for a while, it was pretty broken initially (with regards to the size of the filtered area, if I recall correctly) but has since improved, as far as I know.
You can also do it with a SVG filter: https://codepen.io/ccprog/pen/jJQrdE
I’ve done it a bit different so there is no abrupt inner border where the blur effect ends, but it clears up gradually. This is achieved by laying a blurred “border image” on top of the full-sized un-blurred original.
If you look at the details, there is another difference: your effect still shows some transparency at the outer edges of the image, even after clipping with
overflow: hidden
. In my case, the bluring lessens a bit.Oh, yes. I never had any doubt SVG can provide a lot more flexibility here. It’s just that SVG filters are a bit above my level of competence. Which is also why I need to ask: do you have any idea why this SVG technique only works in Firefox? Chrome and Edge don’t show anything blurred for me.
Oh ah, I should have tested that…
The reason is suprisingly interesting. The feMorphology filter primitive works like this:
In my example, the black-flooded area fills the filter reagion to its borders, which act acording to spec “as a hard clip clipping rectangle”. Firefox interprets this for pixels near the borders as if the pixels outside the border are transparent black, while Chrome seems not to consider these pixels at all. I tend to go with Firefox, here.
Thinking again, I have found another solution for describing a border: shift a flooded area to the top left, shift a second copy to the bottom right, intersect and invert. THis works for both Firefox and Chrome: https://codepen.io/ccprog/pen/ywQEmZ