Using Conic Gradients and CSS Variables to Create a Doughnut Chart Output for a Range Input

Avatar of Ana Tudor
Ana Tudor on

📣 Freelancers, Developers, and Part-Time Agency Owners: Kickstart Your Own Digital Agency with UACADEMY Launch by UGURUS 📣

I recently came across this Pen and my first thought was that it could all be done with just three elements: a wrapper, a range input and an output. On the CSS side, this involves using a conic-gradient() with a stop set to a CSS variable.

Animated gif. Shows the result we want to get: a vertical slider from a minimum of 0 to a maximum of 100, which we can drag to update a doughnut chart alongside it.
The result we want to reproduce.

In mid 2015, Lea Verou unveiled a polyfill for conic-gradient() during a conference talk where she demoed how they can be used for creating pie charts. This polyfill is great for getting started to play with conic-gradient(), as it allows us to use them to build stuff that works across the board. Sadly, it doesn’t work with CSS variables and CSS variables have become a key component of writing efficient code these days.

The good news is that things have moved a bit over the past two years and a half. Chrome and, in general, browsers using Blink that expose flags (like Opera for example) now support conic-gradient() natively (yay!), which means it has become possible to experiment with CSS variables as conic-gradient() stops. All we need to do is have the Experimental Web Platform Features flag enabled in chrome://flags (or, if you’re using Opera, opera://flags):

Screenshot showing the Experimental Web Platform Features flag being enabled in Chrome.
The Experimental Web Platform Features flag enabled in Chrome.

Alright, now we can get started!

The Initial Structure

We start with a wrapper element and a range input:

<div class="wrap">
  <input id="r" type="range"/>
</div>

Note that we don’t have an output element there. This is because we need JavaScript to update the value of the output element anyway and we don’t want to see an ugly useless non-updating element if the JavaScript is disabled or fails for some reason. So we add this element via JavaScript and also, based on whether the current browser supports conic-gradient() or not, we add a class on the wrapper to signal that.

If our browser supports conic-gradient(), the wrapper gets a class of .full and we style the output into a chart. Otherwise, we just have a simple slider without a chart, the output being on the slider thumb.

Screenshots. The top screenshot shows the result in browsers supporting conic-gradient: a chart output alongside the vertical slider. Dragging the slider updates the chart and the value on the slider thumb. The bottom screenshot shows the fallback case (when there is no native support for conic-gradient): a regular horizontal slider that only shows the value on the thumb and has no chart output.
The result in browsers supporting conic-gradient() (top) and the fallback in browsers not supporting it (bottom).

Basic Styles

Before anything else, we want to show a nice-looking slider on the screen in all browsers.

We start with the most basic reset possible and set a background on the body:

$bg: #3d3d4a;

* { margin: 0 }

body { background: $bg }

The second step is to prepare the slider for styling in WebKit browsers by setting -webkit-appearance: none on it and on its thumb (because the track already has it set by default for some reason) and we make sure we level the field by explicitly setting the properties that are inconsistent across browsers like padding, background or font:

[type='range'] {
  &, &::-webkit-slider-thumb { -webkit-appearance: none }
  
  display: block;	
  padding: 0;
  background: transparent;
  font: inherit
}

If you need a refresher on how sliders and their components work in various browsers, check out my detailed article on understanding the range input.

We can now move on to the more interesting part. We decide upon the dimensions of the track and thumb and set these on the slider components via the corresponding mixins. We’ll also include some background values so that we have something visible on the screen as well as a border-radius to prettify things. For both components, we also reset the border to none so that we have consistent results across the board.

$k: .1;
$track-w: 25em;
$track-h: .02*$track-w;
$thumb-d: $k*$track-w;

@mixin track() {
  border: none;
  width: $track-w; height: $track-h;
  border-radius: .5*$track-h;
  background: #343440
}

@mixin thumb() {
  border: none;
  width: $thumb-d; height: $thumb-d;
  border-radius: 50%;
  background: #e6323e
}

[type='range'] {
  /* same styles as before */
  width: $track-w; height: $thumb-d;
	
  &::-webkit-slider-runnable-track { @include track }
  &::-moz-range-track { @include track }
  &::-ms-track { @include track }
	
  &::-webkit-slider-thumb {
    margin-top: .5*($track-h - $thumb-d);
    @include thumb
  }
  &::-moz-range-thumb { @include thumb }
  &::-ms-thumb {
    margin-top: 0;
    @include thumb
  }
}

We add a few more touches like setting a margin, an explicit width and a font on the wrapper:

.wrap {
  margin: 2em auto;
  width: $track-w;
  font: 2vmin trebuchet ms, arial, sans-serif
}

We don’t want to let this get too small or too big, so we limit the font-size:

.wrap {
  @media (max-width: 500px), (max-height: 500px) { font-size: 10px }
  @media (min-width: 1600px), (min-height: 1600px) { font-size: 32px }
}

And we now have a nice cross-browser slider:

See the Pen by thebabydino (@thebabydino) on CodePen.

The JavaScript

We start by getting the slider and the wrapper and creating the output element.

const _R = document.getElementById('r'), 
      _W = _R.parentNode, 
      _O = document.createElement('output');

We create a variable val where we store the current value of our range input:

let val = null;

Next, we have an update() function that checks whether the current slider value is equal to the one we have already stored. If that’s not the case, we update the JavaScript val variable, the text content of the output and the CSS variable --val on the wrapper.

function update() {
  let newval = +_R.value;

  if(val !== newval)
    _W.style.setProperty('--val', _O.value = val = newval)
};

Before we move further with the JavaScript, we set a conic-gradient() on the output from the CSS:

output {
  background: conic-gradient(#e64c65 calc(var(--val)*1%), #41a8ab 0%)
}

We put things in motion by calling the update() function, adding the output to the DOM as a child of the wrapper and then testing whether the computed background-image of the output is the conic-gradient() we have set or not (note that we need to add it to the DOM before we do this).

If the computed background-image is not "none" (as it is the case if we have no native conic-gradient() support), then we add a full class on the wrapper. We also connect the output to the range input via a for attribute.

Via event listeners, we ensure the update() function is called every time we move the slider thumb.

_O.setAttribute('for', _R.id);
update();
_W.appendChild(_O);

if(getComputedStyle(_O).backgroundImage !== 'none')
  _W.classList.add('full');

_R.addEventListener('input', update, false);
_R.addEventListener('change', update, false);

We now have a slider and an output (that shows its value on a variable conic-gradient() background if we’re viewing it in a browser with native conic-gradient() support). Still ugly at this stage, but it’s functional—the output updates when we drag the slider:

See the Pen by thebabydino (@thebabydino) on CodePen.

We’ve also given the output a light color value so that we can see it better and added a % at the end via the ::after pseudo-element. We’ve also hidden the tooltip (::-ms-tooltip) in Edge by setting its display to none.

The no chart case

This is the case when we don’t have conic-gradient() support so we don’t have a chart. The result we’re aiming for can be seen below:

Animated gif. Shows the result we want to get when conic-gradient is not supported natively: a horizontal slider from a minimum of 0 to a maximum of 100, which we can drag to update the value displayed on its thumb.
The result we want to reproduce.

Prettifying the Output

In this case, we absolutely position the output element, make it take the dimensions of the thumb and put its text right in the middle:

.wrap:not(.full) {
  position: relative;
		
  output {    
    position: absolute;
    
    /* ensure it starts from the top */
    top: 0;
    
    /* set dimensions */
    width: $thumb-d; height: $thumb-d
  }
}

/* we'll be using this for the chart case too */
output {
  /* place text in the middle */
  display: flex;
  align-items: center;
  justify-content: center;
}

If you need a refresher on how align-items and justify-content work, check out this comprehensive article on CSS alignment by Patrick Brosset.

The result can be seen in the following Pen, where we’ve also set an outline in order to clearly see the boundaries of the output:

See the Pen by thebabydino (@thebabydino) on CodePen.

This is starting to look like something, but our output isn’t moving with the slider thumb.

Making the Output Move

In order to fix this problem, let’s first remember how the motion of a slider thumb works. In Chrome, the border-box of the thumb moves within the limits of the track’s content-box, while in Firefox and Edge, the thumb’s border-box moves within the limits of the actual slider’s content-box.

While this inconsistency may cause problems in some situations, our use case here is a simple one. We don’t have margins, paddings or borders on the slider or on its components, so the three boxes (content-box, padding-box and border-box) coincide with both the slider itself and its track and thumb components. Furthermore, the width of the three boxes of the actual input coincides with the width of the three boxes of its track.

This means that when the slider value is at its minimum (which we haven’t set explicitly, so it’s the default 0), the left edge of the thumb’s boxes coincides with the left edge of the input (and with that of the track).

Also, when the slider value is at its maximum (again, not set explicitly, so it takes the default value 100), the right edge of the thumb’s boxes coincides with the right edge of the input (and with that of the track). This puts the left edge of the thumb one thumb width ($thumb-d) before (to the left of) the right edge of the slider (and of the track).

The following illustration shows this relative to the input width ($track-w)—this is shown to be 1. The thumb width ($thumb-d) is shown as a fraction k of the input width (since we’ve set it as $thumb-d: $k*$track-w).

Illustration. On the left, it shows the slider thumb at the minimum value. In this case, the left edge of the thumb coincides with the left edge of the input and track. On the right, it shows the slider thumb at the maximum value. In this case, the right edge of the thumb coincides with the right edge of the input and track. The width of the thumb is a fraction k of the width of the track in both cases.
The slider thumb at the minimum value and at the maximum value (live).

From here, we get that the left edge of the thumb has moved by an input width ($track-w) minus a thumb width (thumb-d) in between the minimum and the maximum.

In order to move the output the same way, we use a translation. In its initial position, our output is at the leftmost position of the thumb, the one occupied when the slider value is at its minimum, so the transform we use is translate(0). To move it into the position occupied by the thumb when the slider value is at its maximum, we need to translate it by $track-w - $thumb-d = $track-w*(1 - $k).

Illustration. Shows that the range of motion of the thumb is the track width minus the thumb width as, in going from the minimum value (initial position) to the maximum value (final position), the left edge of the thumb goes from coinciding with slider's left edge to being one thumb width away from the slider's right edge. Since the distance between the slider's left and right edge is one track width, it results that the range of motion is the track width minus the thumb width. Given that the thumb width is a fraction k of the track width, the range of motion relative to the track width is 1 - k.
The range of motion for the slider thumb and, consequently, the output (live).

Alright, but what about the values in between?

Well, remember that every time the slider value gets updated, we’re not only setting the new value to the output‘s text content, but also to a CSS variable --val on the wrapper. This CSS variable goes between 0 at the left end (when the slider value is its minimum, 0 in this case) and 100 at the other end (when the slider value is its maximum, 100 in this case).

So if we translate our output along the horizontal (x) axis by calc(var(--val)/100*#{$track-w - $thumb-d}), this moves it along with the thumb without us needing to do anything else:

See the Pen by thebabydino (@thebabydino) on CodePen.

Note how the above works if we click elsewhere on the track, but not if we try to drag the thumb. This is because the output now sits on top of the thumb and catches our clicks instead.

We fix this problem by setting pointer-events: none on the output.

See the Pen by thebabydino (@thebabydino) on CodePen.

In the demo above, we have also removed the ugly outline on the output element as we don’t need it anymore.

Now that we have a nice fallback for browsers that don’t support conic-gradient() natively, we can move on to building the result we want for those that do (Chrome/ Opera with flag enabled).

The Chart Case

Drawing the Desired Layout

Before we start writing any code, we need to clearly know what we’re trying to achieve. In order to do that, we do a layout sketch with dimensions relative to the track width ($track-w), which is also the width of the input and the edge of the wrapper’s content-box (wrapper padding not included).

This means the content-box of our wrapper is a square of edge 1 (relative to the track width), the input is a rectangle having one edge along and equal to an edge of the wrapper and the other one a fraction k of the same edge, while its thumb is a kxk square.

Illustration. Shows the wrapper as a 1x1 square. The slider is vertical, right aligned horizontally, stretching from top to bottom vertically. Horizontally, it takes up a fraction k of the wrapper's width. The chart is left-aligned horizontally (its left edge goes along the left edge of the wrapper) and middle aligned vertically. The gaps from the chart to the top and bottom edges of the wrapper, as well as to the slider on the right, are all a fraction k of the wrapper's edge length. This makes the chart a square of edge 1 - 2·k.
The desired layout in the chart case (live).

The chart is a square of edge 1 - 2·k, touching the wrapper edge opposite to the slider, a k gap away from the slider and in the middle along the other direction. Given that the edge of the wrapper is 1 and that of the chart is 1 - 2·k, it results we have k gaps between the edges of the wrapper and those of the chart along this direction as well.

Sizing Our Elements

The first step towards getting this layout is making the wrapper square and setting the dimensions of the output to (1 - 2*$k)*100%:

$k: .1;
$track-w: 25em;
$chart-d: (1 - 2*$k)*100%;

.wrap.full {
  width: $track-w;
	
  output {
    width: $chart-d; height: $chart-d
  }
}

The result can be seen below, where we’ve also added some outlines to see things better:

Screenshot. We have a square wrapper containing an input and an output. The input is at the very top, its width equal to that of the wrapper, while its height is a fraction k of the wrapper's edge. The output is a square whose edge is a fraction 1 - 2*k of the wrapper's edge. It's positioned along the left edge of the wrapper, immediately under the input.
The result in a first stage (live demo, only if we have native conic-gradient() support).

This is a good start, as the output is already in the exact spot we want it to be.

Making the Slider Vertical

The “official” way of doing this for WebKit browsers is by setting -webkit-appearance: vertical on the range input. However, this would break the custom styles as they require us to have -webkit-appearance set to none and we cannot have it set to two different values at the same time.

So the only convenient solution we have is to use a transform. As it is, we have the minimum of the slider at the left end of the wrapper and the maximum at its right end. What we want is to have the minimum at the bottom of the wrapper and the maximum at the top of the wrapper.

Illustration. On the left, it shows the slider in it initial position, before applying any transform, at the top of the wrapper. The minimum is at the left end and the maximum is at the right end. On the right, we have the slider in its final position, along the right edge of the wrapper. The minimum is at the bottom and the maximum is at the top. This looks like a rotation in the negative direction (since the positive one would be clockwise).
The initial position of the slider vs. the final position we want to bring it to (live).

This sounds like a 90° rotation in the negative direction (as the clockwise direction is the positive one) around the top right corner (which gives us a transform-origin that’s at 100% horizontally and 0% vertically).

See the Pen by thebabydino (@thebabydino) on CodePen.

That’s a good start, but now our slider is outside the wrapper boundary. In order to decide what’s the best next step to bring it inside in the desired position, we need to understand what this rotation has done. Not only has it rotated the actual input element, but it has also rotated its local system of coordinates. Now its x axis points up and its y axis points to the right.

So in order to bring it inside, along the right edge of the wrapper, we need to translate it by its own height in the negative direction of its y axis after the rotation. This means the final transform chain we apply is rotate(-90deg) translatey(-100%). (Remember that % values used in translate() functions are relative to the dimensions of the translated element itself.)

.wrap.full {
  input {
    transform-origin: 100% 0;
    transform: rotate(-90deg) translatey(-100%)
  }
}

This gives us the desired layout:

Screenshot. We have a square wrapper containing an input and an output. The input is has been rotated such that its bottom edge now coincides with the wrapper's right edge, while its height takes up a fraction k of the top and bottom edges. The output is a square whose edge is a fraction 1 - 2*k of the wrapper's edge. It's positioned along the left edge of the wrapper and in the middle vertically.
The result in a second stage (live demo, only if we have native conic-gradient() support).

Styling the Chart

Of course, the first step is to make it round with border-radius and tweak the color, font-size and font-weight properties.

.wrap.full {
  output {
    border-radius: 50%;
    color: #7a7a7a;
    font-size: 4.25em;
    font-weight: 700
  }
}

You may have noticed we’ve set the dimensions of the chart as (1 - 2*$k)*100% instead of (1 - 2*$k)*$track-w. This is because $track-w is an em value, meaning that the computed pixel equivalent depends on the font-size of the element that uses it.

However, we wanted to be able to increase the font-size here without having to tweak down an em-valued size. This is possible and not that complicated, but compared to just setting the dimensions as % values that don’t depend on the font-size, it’s still a bit of extra work.

Screenshot. We have the same square wrapper with an input and an output. The output is now round and its text is grey and bigger.
The result in a third stage (live demo, only if we have native conic-gradient() support).

From Pie 🥧 to Doughnut 🍩

The simplest way to emulate that hole in the middle where we have the text is to add another background layer on top of the conic-gradient() one. We could probably add some blend modes to do the trick, but that’s not really necessary unless we have an image background. For a solid background as we have here, a simple cover layer will do.

$p: 39%;
background: radial-gradient($bg $p, transparent $p + .5% /* avoid ugly edge */),
            conic-gradient(#e64c65 calc(var(--val)*1%), #41a8ab 0%);

Alright, this does it for the chart itself!

Screenshot. We have the same square wrapper with an input and an output. The output is not a pie anymore, but a doughnut, looking like it has a hole in the middle.
The result in a fourth stage (live demo, only if we have native conic-gradient() support).

Showing the Value on the Thumb

We do this with an absolutely positioned ::after pseudo-element on the wrapper. We give this pseudo-element the dimensions of the thumb and position it in the bottom right corner of the wrapper, precisely where the thumb is when the slider value is at its minimum.

.wrap.full {
  position: relative;
  
  &::after {
    position: absolute;
    right: 0; bottom: 0;
    width: $thumb-d; height: $thumb-d;
    content: '';
  }
}

We also give it an outline just so that we can see it.

Screenshot. We have the same elements as before, plus a square pseudo-element whose edge is a fraction k of the wrapper edge. This pseudo-element is positioned in the bottom right corner of the wrapper.
The result in a fifth stage (live demo, only if we have native conic-gradient() support).

Moving it along with the thumb is achieved exactly the same as in the no chart case, except this time the translation happens along the y axis in the negative direction (instead of along the x axis in the positive direction).

transform: translatey(calc(var(--val)/-100*#{$track-w - $thumb-d}))

In order to be able to drag the thumb underneath, we have to also set pointer-events: none on this pseudo-element. The result can be seen below—dragging the thumb also moves the wrapper’s ::before pseudo-element.

Animated gif. We have the same elements as before and we drag the thumb to show click events pass through the pseudo-element placed on top of it.
The result in a sixth stage (live demo, only if we have native conic-gradient() support).

Alright, but what we really want here is to display the current value using this pseudo-element. Setting its content property to var(--val) does nothing, as --val is a number value, not a string. If we were to set it as a string, we could use it as a value for content, but then we couldn’t use it for calc() anymore.

Fortunately, we can get around this problem with a neat trick using CSS counters:

counter-reset: val var(--val);
content: counter(val)'%';

Now the whole thing is functional, yay!

Screenshot. We have the same elements as before, only now we also have the current value displayed on the thumb.
The result in a seventh stage (live demo, only if we have native conic-gradient() support).

So let’s move on to prettifying and adding some nice touches. We put the text in the middle of the thumb, we make it white, we get rid of all the outlines and we set cursor: pointer on the input:

.wrap.full {
  &::after {
    line-height: $thumb-d;
    color: #fff;
    text-align: center
  }
}

[type='range'] {
  /* same as before */
  cursor: pointer
}

This gives us the following nice result:

Screenshot. We have the same elements as before, only now we also have the current value displayed on the thumb, dead in the middle and all the outlines are gone.
The final look for the chart case (live demo, only if we have native conic-gradient() support).

Eliminating Repetition

One thing that’s nagging me is the fact that we have a bunch of common styles on the output in the no chart case and on the .wrap:after in the chart case.

Screenshot collage. Shows the styles on the output in the no chart case versus the styles on the .wrap:after in the chart case, highlighting the common ones.
Styles on the output in the no chart case vs. styles on the .wrap:after in the chart case.

We can do something about this and that’s using a silent class we then extend:

%thumb-val {
  position: absolute;
  width: $thumb-d; height: $thumb-d;
  color: #fff;
  pointer-events: none
}

.wrap {
  &:not(.full) output {
    @extend %thumb-val;
    /* same other styles */
  }

  &:after {
    @extend %thumb-val;
    /* same other styles */
  }
}

Nice Focus Styles

Let’s say we don’t want to have that ugly outline on :focus, but we also want to clearly differentiate this state visually. So what could we do? Well, let’s say we make the thumb smaller and a bit desaturated when the input isn’t focused and that we also hide the text in this case.

Sounds like a cool idea…but, since we have no parent selector, we cannot trigger a property change on the ::after of the slider’s parent when the slider gets or loses focus. Ugh.

What we can do instead is use the output‘s other pseudo-element (the ::before) to display the value on the thumb. This doesn’t come without any of its own complications, which we’ll discuss in a moment, but it allows us to do something like this:

[type='range']:focus + output:before { /* focus styles */ }

The problem with taking this approach is that we’re blowing up the font on the output itself, but for its ::before we need it to be the same size and weight as on the wrapper.

We can solve this by setting a relative font size as a Sass variable $fsr and then use that value to blow up the font on the actual output and bring it back down to its previous size on the output:before pseudo-element.

$fsr: 4;

.wrap {
  color: $fg;
	
  &.full {
    output {
      font-size: $fsr*1em;
      
      &:before {
        /* same styles as we had on .wrap:after */
        font-size: 1em/$fsr;
        font-weight: 200;
      }
    }
  }
}

Other than that, we just move the CSS declarations we had on the .wrap:after on the output:before.

Screenshot collage. Shows the styles on the .wrap.full:after (left) and highlights how all of them can be found afterwards on the .wrap.full output:before (right), in addition to those bringing the font down to the size and weight it has on the wrapper.
Styles on the wrapper pseudo-element vs. on the output pseudo-element.

Alright, now we can move on to the final step of differentiating between the normal and the focused look.

We start by hiding the ugly :focus state outline and the value on the thumb when the slider isn’t focused:

%thumb-val {
  /* same styles as before */
  opacity: 0;
}

[type='range']:focus {
  outline: none;
	
  .wrap:not(.full) & + output, 
  .wrap.full & + output:before { opacity: 1 }
}
Animated gif. The value on the thumb isn't visible when the slider isn't focused, as illustrated in this gif by clicking in and out of the boundaries of the range input.
The value on the thumb is only visible when the slider gets focus (live demo, only if we have native conic-gradient() support).

Next, we set different styles for the normal and focused states of the slider thumb:

@mixin thumb() {
  /* same styles as before */
  transform: scale(.7);
  filter: saturate(.7)
}

@mixin thumb-focus() {
  transform: none;
  filter: none
}

[type='range']:focus {
  /* same as before */
  &::-webkit-slider-thumb { @include thumb-focus }
  &::-moz-range-thumb { @include thumb-focus }
  &::-ms-thumb { @include thumb-focus }
}
Animated gif. The thumb is scaled down and desaturated as long as the slider isn't focused. Clicking on the range input to make it receive focus brings both the scale and saturation factor to 1.
The thumb is scaled down and desaturated as long as the slider isn’t focused (live demo, only if we have native conic-gradient() support).

The last step is to add a transition between these states:

$t: .5s;

@mixin thumb() {
  /* same styles as before */
  transition: transform $t linear, filter $t
}

%thumb-val {
  /* same styles as before */
  transition: opacity $t ease-in-out
}
Animated gif. Clicking within/ outside the boundaries of the range input causes it to gain/ lose focus and we have a transition between the two states.
The demo with a transition between the normal and focused state (live demo, only if we have native conic-gradient() support).

What About Screen Readers?

Since screen readers these days read generated content, we’d have the % value read twice in this case. So we go around this by setting role='img' on our output and then putting the current value that we want to be read in an aria-label attribute:

let conic = false;

function update() {
  let newval = +_R.value;

  if(val !== newval) {
    _W.style.setProperty('--val', _O.value = val = newval);
    if(conic) _O.setAttribute('aria-label', `${val}%`)
  }
};

update();

_O.setAttribute('for', _R.id);
_W.appendChild(_O);

if(getComputedStyle(_O).backgroundImage !== 'none') {
  conic = true;
  _W.classList.add('full');
  _O.setAttribute('role', 'img');
  _O.setAttribute('aria-label', `${val}%`)
}

The final demo can be found below. Note that we only see the fallback if your browser has no native conic-gradient() support

See the Pen by thebabydino (@thebabydino) on CodePen.

Final Words

While the browser support for this is still poor, the situation will change. For now it’s just Blink browsers that expose flags, but Safari lists conic-gradient() as being in development, so things are already getting better.

If you’d like cross-browser support to become a reality sooner rather than later, you can contribute by voting for conic-gradient() implementation in Edge or by leaving a comment on this Firefox bug on why you think this is important or what use cases you have in mind. here are mine for inspiration.