htmlcsscss-position

How to position rounded end borders correctly on a circular graph using only CSS?


I'm trying to create a circular graph using only CSS and HTML, where each segment has a border with rounded ends at both the start and end. However, I'm struggling with positioning the ::before and ::after pseudo-elements to correctly mark the start and end of each segment's border.

Here's what I'm currently working with:

I'm using calc() along with cos() and sin() to try to calculate the position of the ::after pseudo-element based on the percentage of the circle filled (--value). However, it doesn't seem to be working correctly.

How can I accurately position the ::after pseudo-element at the end of the filled segment on the circular graph, ensuring that it aligns with the edge of the filled arc?

Thank you for your help!

This is a simplified version of my CSS:

.circular {
        &__graph {
        grid-area: b;
        max-width: 100%;
        width: 100%;
        margin-top: 36px;
        justify-content: center;
        display: flex;
        flex-direction: column;
        padding-left: 65px;

        &-chart{
            display: flex;
            width: 100%;
            height: 500px;
            } 
    }

    &__circles{
        display: flex;
        align-content: center;
        justify-content: center;
        align-items: center;
        position: relative;
        width: 100%;
    }

&__dial-circle {
        --size: 320px;
        --thickness: 12px;
        width: var(--size);
        height: var(--size);
        border-radius: 60%;
        background: conic-gradient(var(--color, #1e90ff) calc(var(--value)* 1%), #F9F9F9 0);
        mask: radial-gradient(farthest-side, transparent calc(100% - var(--thickness)), white calc(100% - var(--thickness) + 1px));
        position: absolute;
        //transform: rotate(180deg);


        &::before, &::after {
            content: ' ';
            width: 12px;
            height: 12px;
            position: absolute;
            background: var(--color, #1e90ff);
            left: calc(50% - 7px);
            top: 0;
            border-radius: 50%;
        }

        &::after{
            border: 1px solid red;
            --angle: calc(var(--value) * 3.6);
            --radius: calc((var(--size) / 2) - var(--thickness));

            left: calc(50% + var(--radius) * cos(var(--angle) * (pi / 180)) - 6px);
            top: calc(50% + var(--radius) * sin(var(--angle) * (pi / 180)) - 6px);

        }
    }
}

Here's my HTML for this example:

<div class="circular__graph-chart">
    <div class="circular__circles">
        <div class="circular__dial-circle" style="--color: rgb(39, 63, 82);--value: 79.5;--size: 314px;"></div>
        <div class="circular__dial-circle" style="--color: rgb(36, 91, 120);--value: 72.8;--size: 282px"></div>
        <div class="circular__dial-circle" style="--color: rgb(105, 129, 142);--value: 86;--size: 250px"></div>
        <div class="circular__dial-circle" style="--color: rgb(189, 200, 201);--value: 68.2;--size: 217px"></div>
        <div class="circular__dial-circle" style="--color: rgb(231, 195, 184);--value: 79.1;--size: 185px"></div>
        <div class="circular__dial-circle" style="--color: rgb(195, 175, 180);--value: 86.8;--size: 152px"></div>
    </div>
</div>

Here's a live example: https://codepen.io/JonJonCS/pen/BaXopEE

I tried using the cos() and sin() functions in calc() to position the ::after element at the end of the filled segment. I expected the ::after element to appear exactly at the edge of the filled arc, but instead, it's positioned incorrectly and doesn't align with the edge of the segment. I was hoping the red circle would mark the exact end of the border.


Solution

  • With visual debugging, I noticed that all "end caps" were off by 90 degrees. Also, their radius was incorrect.

    After adjusting --angle by minus 90 (degrees) and --radius by minus half (instead of all) its thickness in .circular__dial-circle::after, the end cap was properly aligned:

    .circular__graph {
      grid-area: b;
      max-width: 100%;
      width: 100%;
      margin-top: 36px;
      justify-content: center;
      display: flex;
      flex-direction: column;
      padding-left: 65px;
    }
    
    .circular__graph-chart {
      display: flex;
      width: 100%;
      height: 500px;
    }
    
    .circular__circles {
      display: flex;
      align-content: center;
      justify-content: center;
      align-items: center;
      position: relative;
      width: 100%;
    }
    
    .circular__dial-circle {
      --size: 320px;
      --thickness: 12px;
      width: var(--size);
      height: var(--size);
      border-radius: 60%;
      background: conic-gradient(var(--color, #1e90ff) calc(var(--value)* 1%), #F9F9F9 0);
      mask: radial-gradient(farthest-side, transparent calc(100% - var(--thickness)), white calc(100% - var(--thickness) + 1px));
      position: absolute;
      /*transform: rotate(180deg);*/
    }
    
    .circular__dial-circle::before,
    .circular__dial-circle::after {
      content: ' ';
      width: 12px;
      height: 12px;
      position: absolute;
      background: var(--color, #1e90ff);
      left: calc(50% - 7px);
      top: 0;
      border-radius: 50%;
    }
    
    .circular__dial-circle::after {
      /*border: 1px solid red;*/ /* Commented out for now */
      --angle: calc(var(--value) * 3.6 - 90); /* Added: "- 90" */
      --radius: calc((var(--size) / 2) - var(--thickness) / 2); /* Added: The last "/ 2" */
    
      left: calc(50% + var(--radius) * cos(var(--angle) * (pi / 180)) - 6px);
      top: calc(50% + var(--radius) * sin(var(--angle) * (pi / 180)) - 6px);
    }
    <div class="circular__graph-chart">
        <div class="circular__circles">
            <div class="circular__dial-circle" style="--color: rgb(39, 63, 82);--value: 79.5;--size: 314px;"></div>
            <div class="circular__dial-circle" style="--color: rgb(36, 91, 120);--value: 72.8;--size: 282px"></div>
            <div class="circular__dial-circle" style="--color: rgb(105, 129, 142);--value: 86;--size: 250px"></div>
            <div class="circular__dial-circle" style="--color: rgb(189, 200, 201);--value: 68.2;--size: 217px"></div>
            <div class="circular__dial-circle" style="--color: rgb(231, 195, 184);--value: 79.1;--size: 185px"></div>
            <div class="circular__dial-circle" style="--color: rgb(195, 175, 180);--value: 86.8;--size: 152px"></div>
        </div>
    </div>

    Personally, I find using polar coordinates (angle and radius) with rotate() more intuitive:

    .circular__graph {
      grid-area: b;
      max-width: 100%;
      width: 100%;
      margin-top: 36px;
      justify-content: center;
      display: flex;
      flex-direction: column;
      padding-left: 65px;
    }
    
    .circular__graph-chart {
      display: flex;
      width: 100%;
      height: 500px;
    }
    
    .circular__circles {
      display: flex;
      align-content: center;
      justify-content: center;
      align-items: center;
      position: relative;
      width: 100%;
    }
    
    .circular__dial-circle {
      --size: 320px;
      --thickness: 12px;
      width: var(--size);
      height: var(--size);
      border-radius: 60%;
      background: conic-gradient(var(--color, #1e90ff) calc(var(--value)* 1%), #F9F9F9 0);
      mask: radial-gradient(farthest-side, transparent calc(100% - var(--thickness)), white calc(100% - var(--thickness) + 1px));
      position: absolute;
      /*transform: rotate(180deg);*/
    }
    
    .circular__dial-circle::before,
    .circular__dial-circle::after {
      content: ' ';
      width: 12px;
      height: 12px;
      position: absolute;
      background: var(--color, #1e90ff);
      left: calc(50% - 7px);
      top: 0;
      border-radius: 50%;
    }
    
    .circular__dial-circle::after {
      /*border: 1px solid red;*/ /* Commented out for now */
      --angle: calc(var(--value) * 3.6deg); /* Same as your `* 3.6`, but with unit */
      --radius: calc((var(--size) - var(--thickness)) / 2); /* Added: The last "/ 2" */
    
      left: 50%;
      top: 50%;
      
      transform:
        translate(-50%, -50%) /* With `left`, `top`: Place centered */
        rotate(var(--angle)) /* Use `--angle` */
        translateY(calc(-1 * var(--radius))); /* Move outwards (along rotation, back to initial) */
    }
    <div class="circular__graph-chart">
        <div class="circular__circles">
            <div class="circular__dial-circle" style="--color: rgb(39, 63, 82);--value: 79.5;--size: 314px;"></div>
            <div class="circular__dial-circle" style="--color: rgb(36, 91, 120);--value: 72.8;--size: 282px"></div>
            <div class="circular__dial-circle" style="--color: rgb(105, 129, 142);--value: 86;--size: 250px"></div>
            <div class="circular__dial-circle" style="--color: rgb(189, 200, 201);--value: 68.2;--size: 217px"></div>
            <div class="circular__dial-circle" style="--color: rgb(231, 195, 184);--value: 79.1;--size: 185px"></div>
            <div class="circular__dial-circle" style="--color: rgb(195, 175, 180);--value: 86.8;--size: 152px"></div>
        </div>
    </div>

    1. Center element and its transform origin (with left, top and translate(-50%, -50%)).
    2. Rotate as desired (angle of polar coordinate).
    3. Offset as desired (radius of polar coordinate).
    4. Reverse rotation (so the final element isn't rotated).

    Here, step 4 is redundant because a circle is similar to its rotation.


    Graphics such as these are usually easier in SVG. Take a look at Paulie_D's answer.