I know you can make a circle in CSS using the border radius hack. But is there any way to make them have segments like this picture? Is there a way of doing this through HTML and CSS but not JS?
Going through a few cases here:
We're probably given a palette to paint each slice, so I'd use SCSS to generate the list of stops for a conic-gradient()
.
Using a random palette from coolors.co:
$c: #f94144, #f3722c, #f8961e, #f9c74f, #90be6d, #43aa8b, #577590;
We create a SCSS function to generate equal slices out of it, that is, return a stop list, equally spaced. The most basic version would be:
@function stops($c) {
$n: length($c); // number of slices
$p: 100%/$n; // slice angle as a % of circle
$l: (); // list of stops, initially empty
@for $i from 1 through $n {
$l: $l, nth($c, $i) 0% $i*$p
}
@return $l
}
This can give us ugly decimals and it's going to also explicitly specify the default first and last stop positions, which we can omit. For the particular palette from above, we'd get:
#f94144 0% 14.2857142857%, #f3722c 0% 28.5714285714%,
#f8961e 0% 42.8571428571%, #f9c74f 0% 57.1428571429%,
#90be6d 0% 71.4285714286%, #43aa8b 0% 85.7142857143%,
#577590 0% 100%
Meh. A better version would be:
@function stops($c) {
$n: length($c); // number of slices
$p: 100%/$n; // slice angle as a % of circle
$l: (); // list of stops, initially empty
@for $i from 1 through $n {
$l: $l, nth($c, $i)
if($i > 1, 0%, unquote(''))
if($i < $n, round($i*$p), unquote(''))
}
@return $l
}
This produces the following list of stops:
#f94144 14%, #f3722c 0% 29%, #f8961e 0% 43%, #f9c74f 0% 57%,
#90be6d 0% 71%, #43aa8b 0% 86%, #577590 0%
Much better!
We use this inside a conic-gradient()
:
.pie {
width: 20em; /* set width to desired pie diameter */
aspect-ratio: 1; /* make the element square */
border-radius: 50%; /* turn square into disc */
/* equally-sized slices */
background: conic-gradient(stops($c))
}
4 CSS declarations is all we need here. If we don't want the first slice to start from 12 o'clock, we can just specify another start angle for the conic-gradient()
:
background: conic-gradient(from 17deg, stops($c))
You can see it live inside a snippet:
.pie {
width: 16em; /* set width to desired pie diameter */
aspect-ratio: 1; /* make pie element square */
border-radius: 50%; /* turn square into disc */
/* equally sized slices */
background:
conic-gradient(from 17deg, #f94144 14%, #f3722c 0% 29%, #f8961e 0% 43%,
#f9c74f 0% 57%, #90be6d 0% 71%, #43aa8b 0% 86%, #577590 0%)
}
<div class='pie'></div>
In this case, we're given a palette with weights. As a Sass map, that would be:
$c: (#f94144: 6, #f3722c: 3, #f8961e: 2, #f9c74f: 5, #90be6d: 7, #43aa8b: 1, #577590: 4);
The stop-generating function becomes:
@function stops($c) {
$n: length($c); // number of slices
$s: 0; // sum of weights, initially 0
$p: 0%; // slice start, 0% initially
$l: (); // list of stops, initially empty
// compute sum of weights
@each $k, $v in $c { $s: $s + $v }
// get list of stops with their positions
@each $k, $v in $c {
$l: $l, $k if($p > 0%, 0%, unquote(''));
$p: $p + $v/$s*100%;
$l: $l if(round($p) < 100%, round($p), unquote(''));
}
@return $l
}
Live inside a snippet:
.pie {
width: 15em; /* set width to desired pie diameter */
aspect-ratio: 1; /* make the element square */
border-radius: 50%; /* turn square into disc */
/* randomly sized slices */
background:
conic-gradient(from 17deg, #f94144 21%, #f3722c 0% 32%, #f8961e 0% 39%,
#f9c74f 0% 57%, #90be6d 0% 82%, #43aa8b 0% 86%, #577590 0%)
}
<div class='pie'></div>
We generate the HTML starting from a palette. Whether the HTML is generated using PHP, JS, some HTML preprocessor, whatever... this matters less as the basic idea behind is the same.
For example, with Pug:
- let c = ['f94144', 'f3722c', 'f8961e', 'f9c74f', '90be6d', '43aa8b', '577590'];
- let n = c.length;
.pie(style=`--n: ${n}`)
- for(let i = 0;i < n; i++)
.slice(style=`--i: ${i}; --c: ${c[i]}`)
The CSS makes the .pie
a square inside which we stack all .slice
elements (all covering their parent fully, same size squares as the .pie
). A border-radius
of 50%
turns these squares into discs.
We compute the angle corresponding to a slice, which is 360°
(or 1 full turn) divided by the number of slices --n
. We also compute the rotation for each slice, which is its index multiplied by this slice angle. For simplicity, the content is just the slice numbering in a pseudo. A little snippet just to see what we have so far:
div { display: grid }
.pie {
width: 9em;
aspect-ratio: 1
}
.slice {
--ba: 1turn/var(--n); /* angle of one slice */
--ca: var(--i)*var(--ba); /* slice rotation angle */
grid-area: 1/ 1; /* stack them all on top of each other */
place-content: center end; /* text at 3 o'clock pre rotation */
padding: .5em; /* space from circle edge to text */
border-radius: 50%; /* turn square into disc */
rotate: calc(var(--ca));
box-shadow: 0 0 2px red;
counter-reset: i calc(var(--i) + 1);
&::after { content: counter(i) }
}
<div class="pie" style="--n: 7">
<div class="slice" style="--i: 0; --c: #f94144"></div>
<div class="slice" style="--i: 1; --c: #f3722c"></div>
<div class="slice" style="--i: 2; --c: #f8961e"></div>
<div class="slice" style="--i: 3; --c: #f9c74f"></div>
<div class="slice" style="--i: 4; --c: #90be6d"></div>
<div class="slice" style="--i: 5; --c: #43aa8b"></div>
<div class="slice" style="--i: 6; --c: #577590"></div>
</div>
If we don't want the first slice to be at 3 o'clock, we just add an offset angle to the rotation angle:
--ca: var(--i)*var(--ba) + var(--oa, 17deg);
In order to have the text upright for all slices, we reverse the .slice
rotation on the ::after
(same angle, only with minus).
For the slice backgrounds, we use conic-gradient()
to create slices around the 3 o'clock position of the text content. The default start position for a conic-gradient()
is at 12 o'clock and the positive direction is clockwise, so 3 o'clock is at +90°
. The slice needs to start half a slice angle (.5*var(--ba)
) before that.
On :hover
on a .slice
, we want it to move outwards, that is, in the positive direction of the post-rotation x axis. This means we need to apply a translation after the rotation. But whenever we have separate transform properties:
rotate: var(--a);
translate: var(--x);
... the rotation gets applied after the translation!
So we need to ditch the individual rotate
property and make the rotation a part of a transform
chain for the .slice
. No need to do that for the ::after
, since we just have a plain rotation there.
Another live snippet to show what we have so far:
div { display: grid }
.pie {
margin: 1em;
width: 9em; /* set width to desired pie diameter */
aspect-ratio: 1; /* make element square */
font: 2em sans-serif
}
.slice {
--ba: 1turn/var(--n); /* angle of one slice */
--ca: var(--i)*var(--ba) + var(--oa, 17deg); /* slice rotation */
grid-area: 1/ 1; /* stack them all on top of each other */
place-content: center end; /* text at 3 o'clock pre rotation */
padding: .5em; /* space from circle edge to text */
border-radius: 50%; /* turn square into disc */
transform: /* need rotation before translation */
rotate(calc(var(--ca)))
/* non-zero only in hover case */
translate(calc(var(--hov, 0)*1em));
background: /* 90° = 3 o'clock; start half slice angle before */
conic-gradient(from calc(90deg - .5*var(--ba)),
/* after one slice angle, full transparency */
var(--c) calc(var(--ba)), #0000 0%);
transition: .3s;
counter-reset: i calc(var(--i) + 1);
&::after {
/* reverse parent rotation for upright text */
rotate: calc(-1*(var(--ca)));
content: counter(i)
}
&:hover { --hov: 1 } /* flip hover flag */
}
<div class="pie" style="--n: 7">
<div class="slice" style="--i: 0; --c: #f94144"></div>
<div class="slice" style="--i: 1; --c: #f3722c"></div>
<div class="slice" style="--i: 2; --c: #f8961e"></div>
<div class="slice" style="--i: 3; --c: #f9c74f"></div>
<div class="slice" style="--i: 4; --c: #90be6d"></div>
<div class="slice" style="--i: 5; --c: #43aa8b"></div>
<div class="slice" style="--i: 6; --c: #577590"></div>
</div>
If you try to hover, you'll see the problem: the only slice moving out is the last one. That's because all .slice
elements are equally sized discs stacked on top of the other. What we see of them is just the visible part of their conic-gradient()
background. :hover
is always triggered just for the one on top, the last one.
To fix that, we need to clip the .slice
elements to just the visible conic-gradient()
slice.
In order to better understand the clip-path
needed, consider this illustration.
The first point is dead in the middle of the element, at 50%
horizontally and 50%
vertically. The other two are on its right edge, a distance dy
above and below the middle (50%
) point. Out of the highighted yellow right triangle, we can compute dy
, since the tangent of half the slice angle is dy/50%
.
Note that since we're now using clip-path
, we don't need the conic-gradient()
anymore and can just set background
to --c
.
Final snippet for this case, which also adds in a saturation change on :hover
.
div { display: grid }
.pie {
margin: 1em;
width: 9em; /* set width to desired pie diameter */
aspect-ratio: 1; /* make element square */
font: 2em sans-serif
}
.slice {
--hov: 0;
--ba: 1turn/var(--n); /* angle of one slice */
--ca: var(--i)*var(--ba) + var(--oa, 17deg); /* slice rotation */
--dy: 50%*tan(.5*var(--ba)); /* half a slice height */
grid-area: 1/ 1; /* stack them all on top of each other */
place-content: center end; /* text at 3 o'clock pre rotation */
padding: .5em; /* space from circle edge to text */
border-radius: 50%; /* turn square into disc */
transform: /* need rotation before translation */
rotate(calc(var(--ca)))
/* non-zero only in hover case */
translate(calc(var(--hov)*1em));
background: var(--c);
/* so hover is only triggered inside slice area */
clip-path:
polygon(50% 50%,
100% calc(50% - var(--dy)),
100% calc(50% + var(--dy)));
filter: saturate(var(--hov));
transition: .3s;
counter-reset: i calc(var(--i) + 1);
&::after {
/* reverse parent rotation for upright text */
rotate: calc(-1*(var(--ca)));
content: counter(i)
}
&:hover { --hov: 1 } /* flip hover flag */
}
<div class="pie" style="--n: 7">
<div class="slice" style="--i: 0; --c: #f94144"></div>
<div class="slice" style="--i: 1; --c: #f3722c"></div>
<div class="slice" style="--i: 2; --c: #f8961e"></div>
<div class="slice" style="--i: 3; --c: #f9c74f"></div>
<div class="slice" style="--i: 4; --c: #90be6d"></div>
<div class="slice" style="--i: 5; --c: #43aa8b"></div>
<div class="slice" style="--i: 6; --c: #577590"></div>
</div>
This is a combination of the previous two. The Pug generating the HTML gets modified to something like this:
- let c = [
- { 'f94144': 6 },
- { 'f3722c': 3 },
- { 'f8961e': 2 },
- { 'f9c74f': 5 },
- { '90be6d': 7 },
- { '43aa8b': 1 },
- { '577590': 4 }
- ];
- let n = c.length;
- let v = c.map(k => +Object.values(k));
- let h = v.map((k, i) => i ? k : .5*k);
- function sum(a) { return a.reduce((c, s) => c + s, 0) }
.pie(style=`--s: ${sum(v)}`)
- for(let i = 0; i < n; i++)
.slice(style=`--c: #${Object.keys(c[i])};
--v: ${v[i]};
--i: ${sum(h.slice(0, i))}`)
You can see the generated HTML and the changes to the CSS in the following snippet:
div { display: grid }
.pie {
margin: 1em;
width: 9em; /* set width to desired pie diameter */
aspect-ratio: 1; /* make element square */
font: 2em sans-serif
}
.slice {
--u: calc(1turn/var(--s)); /* unit angle */
/* current slice rotation angle */
--ca: calc(var(--v)*var(--u));
/* current slice rotation angle */
--sa: calc((var(--i) + .5*var(--v)*min(1, var(--i)))*var(--u));
--dy: 50%*tan(.5*var(--ca)); /* half a slice height */
grid-area: 1/ 1; /* stack them all on top of each other */
place-content: center end; /* text at 3 o'clock pre rotation */
padding: .5em; /* space from circle edge to text */
border-radius: 50%; /* turn square into disc */
transform: /* need rotation before translation */
rotate(calc(var(--sa)))
/* non-zero only in hover case */
translate(calc(var(--hov, 0)*1em));
background: var(--c);
/* so hover is only triggered inside slice area */
clip-path:
polygon(50% 50%,
100% calc(50% - var(--dy)),
100% calc(50% + var(--dy)));
transition: .3s;
counter-increment: i;
&::after {
/* reverse parent rotation for upright text */
rotate: calc(-1*var(--sa));
content: counter(i)
}
&:hover { --hov: 1 } /* flip hover flag */
}
<div class="pie" style="--s: 28">
<div class="slice" style="--c: #f94144; --v: 6; --i: 0"></div>
<div class="slice" style="--c: #f3722c; --v: 3; --i: 3"></div>
<div class="slice" style="--c: #f8961e; --v: 2; --i: 6"></div>
<div class="slice" style="--c: #f9c74f; --v: 5; --i: 8"></div>
<div class="slice" style="--c: #90be6d; --v: 7; --i: 13"></div>
<div class="slice" style="--c: #43aa8b; --v: 1; --i: 20"></div>
<div class="slice" style="--c: #577590; --v: 4; --i: 21"></div>
</div>
You can play with the generating Pug code in this CodePen demo.
Yes, you can get such slices of custom angles using either one of the following two methods:
For #2, see also this very much simplified example I did right now.
.pie {
overflow:hidden;
position: relative;
margin: 1em auto;
border: dashed 1px;
padding: 0;
width: 32em; height: 32em;
border-radius: 50%;
list-style: none;
}
.slice {
overflow: hidden;
position: absolute;
top: 0; right: 0;
width: 50%; height: 50%;
transform-origin: 0% 100%;
}
.slice:first-child {
transform: rotate(15deg) skewY(-22.5deg);
}
.slice-contents {
position: absolute;
left: -100%;
width: 200%; height: 200%;
border-radius: 50%;
background: lightblue;
}
.slice:first-child .slice-contents {
transform: skewY(22.5deg); /* unskew slice contents */
}
.slice:hover .slice-contents { background: violet; } /* highlight on hover */
<ul class='pie'>
<li class='slice'>
<div class='slice-contents'></div>
</li>
<!-- you can add more slices here -->
</ul>