htmlcsscss-shapes

Segments in a circle using CSS


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?

enter image description here


Solution

  • 2024 solution

    Going through a few cases here:

    slices don't need to be elements and they're equal

    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>

    slices don't need to be elements and they're not equal

    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>

    slices need content/ to animate out and they're equal

    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.

    geometry 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>

    slices need content/ to animate out and they're not equal

    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.


    2013 solution (preserved for web history reasons)

    Yes, you can get such slices of custom angles using either one of the following two methods:

    1. If you don't need the slices to be elements themselves, the you can simply do it with one element and linear gradients - see this rainbow wheel I did last month.
    2. If you need the slices to be elements themselves, then you can do it by chaining rotate and skew transforms - see this circular menu I did a while ago.

    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>