Ever since border-radius
appeared in the CSS toolkit, people have been making circular or rounded elements. They're great for a variety of reasons, but I find myself reaching for them a lot when making buttons and small, sticker-like labels. In order to help them stand-out from the background, I often set a border around them; the fact that you can do this in a couple of lines of code and get nicely rounded edges remains a bit magical to me. (Yes, I do remember the days of carefully positioning images at each corner of an element to fake this look, how can you tell? 😅)
Recently, I was noodling on a design that used one of these circular elements as a divider between blocks of content. You'd get a paragraph or two of text, and then a circular label that indicated a subtle change in context. In this case, the content was appearing in chronological order, and the circular element was stating division in time; something that you might see, for instance, on a conference schedule page, separating different speakers.
In and of itself, this would be a simple enough piece of content to implement, probably being a single <time>
element, maybe with an <hr>
or similar to mark the division. However, this particular design called for only the lower half of the circle to have a border. It looked a little like this:
Making it circular and setting the relevant fonts, background colours, etc. are all easy:
time {
/* Make it circular */
width: max-content;
aspect-ratio: 1 / 1;
/* Style the container */
padding: 1rem;
background: white;
/* Center the text */
display: flex;
place-items: center;
}
But what about that border?
I figured that I had two good options[1]:
- Use a pseudo-element to draw a semicircle and then absolutely position it behind the
<time>
element itself; - Manipulate a border to somehow only appear on one half of the element.
For a few factors not worth mentioning here, the pseudo-element option was quickly taken off the table, so that left me with a border.
Border Control
You may be thinking that using a border
is clearly the obvious solution to begin with, but the reality isn't as simple as it first appears. The ideal option would involve setting border-bottom
(or, preferably, border-block-end
) and having this magically just work. However, if you try it out, you'll see that the result isn't exactly fit for purpose:
It's certainly an interesting effect, and it is putting a border across the lower half of the circle, but it doesn't exactly match the design. Perhaps if we add a border to one of the sides as well[2] 🤔
Nope, that's worse! 😂 To understand what's happening here, it's perhaps simplest to remove the border-radius
for a second.
That should hopefully make it clear that a simple border
attribute is never going to do what we want. Once you start rounding the edges of an element, borders have to try and preserve the resultant arc, but they cannot know that you've fundamentally changed the shape (in this instance, to a circle), so they still try to play by the rules of rectangles and polygons. The result are borders that effectively curve to infinity. If we want a hard edge within a circular border, we're going to have to approach this differently.
A Difficulty Gradient
Gradients have unlocked a lot of design opportunities within CSS. Creating a "hard stop" gradient is fairly trivial: all you need are two colours and a linear gradient that switches them at the 50% mark, e.g:
linear-gradient(red 0% 50%, blue 50% 100%);
For our purpose, we don't want to have two colours per se, but rather one colour and one, well, lack of colour. Thankfully CSS includes the ever-useful transparent
within its valid colour options, so we can use the same logic on a linear gradient which only has one "true" colour ‒ in this case: rebeccapurple
‒ and pairs it with, well, nothing. We can even define the direction, forcing the gradient to render top-to-bottom, which results in the following statement:
linear-gradient(to bottom, transparent 0% 50%, rebeccapurple 50% 100%);
Unfortunately, the border-color
attribute in CSS does not accept gradients as an option... but the background-image
attribute does! And what's more, it accepts multiple gradients, which can then be layered 🎉
First, we'll remove the existing background-color
statement and replace it with two new options: a transparent border
that will define the width and style of our final design; and a background-image
with two gradients, one to control the colour of our content area and the other that will, eventually, control our border colour:
time {
...
/* Style the border */
border: 0.5em solid transparent;
border-image:
linear-gradient(white, white),
linear-gradient(to bottom, transparent 0% 50%, rebeccapurple 50% 100%);
}
Right now that doesn't look like much ‒ all it really seems to do is make the circle larger and give it some odd antialiasing artefacts around the edges (at least, it does in Firefox on Windows) 😅 But under the hood, this has set the stage very nicely.
What we're actually seeing here is that the two gradients are being applied to the entire element, with the first gradient layering above the second one. Because that initial gradient is just fully white, and because our border is transparent, both the border and the content area appear as solid white (ignoring the clipping around the edges). Thankfully, we have a surprising amount of control as to how these gradients are rendered, allowing us to modify the default behaviour significantly.
First, we're going to use background-clip
to change which parts of the element each gradient can be applied to. By default, this uses the value border-box
, allowing it to set the background across entire elements, borders and all. We only want that first, white gradient to apply within the element, so we can change the clip value to padding-box
, which restricts it to the content area of the element, plus any padding. The second gradient can be left with the value of border-box
, so that it can expand into our transparent border:
background-clip: padding-box, border-box;
That has an immediate impact, though it definitely isn't exactly the desired outcome:
We've successfully restricted the white gradient to only fill the content area ‒ effectively mimicking a regular ol' background-color
in the process ‒ but what's going on with that border? We're now seeing our purple/transparent gradient applied to our outer edge, but for some reason it's "wrapping" vertically, rather than neatly starting at the top of the element.
To change that, let's take a look at background-origin
. This property lets us define where a background should originate. The problem we're running into is that, by default, the value here is set to padding-box
. So whilst we've specified for our second gradient to render to bottom
(meaning that it should start at the top of the element), the background-origin
is setting that top point at the edge of the padding area. That explains why the gradient is cut off at the bottom, and the reason it appears to "wrap" onto the top is that it's trying to repeat to fill the available space. So, if we change background-origin
to a value of border-box
, then it will begin to render the gradient from the top-edge of the border all the way down to the bottom-edge, giving us the semicircle of purple that we've been aiming for:
Making it Modular
I'm pretty happy with how this solution all came together. It builds on some of the work I've done in the past getting rounded borders on <input>
elements, and has really helped me understand some of the more esoteric background and border options that we now have available within CSS. But before I sign off, I wanted to create a single block of code that could be reused anywhere else that I wanted to apply a similar effect. I've augmented this with some Custom Properties to make it a bit more flexible and customisable.
Critically, this lets you define which side of the circle you want the border to be placed on, which I think is kinda neat 😅
So, without any more gilding of the lily, here is that code (or you can check it out on CodePen):
.semicircle-border {
--thickness: 0.5em;
--border-color: orange;
--background-color: slategray;
--font-color: white;
--semicircle-side: left;
/* Border */
border: var(--thickness) solid transparent;
background-image: linear-gradient(var(--background-color), var(--background-color)), linear-gradient(to var(--semicircle-side), transparent 0% 50%, var(--border-color) 50% 100%);
background-origin: border-box;
background-clip: padding-box, border-box;
border-radius: 50%;
/* General styling */
display: flex;
place-items: center;
width: max-content;
aspect-ratio: 1 / 1;
color: var(--font-color);
padding: 1rem;
}