Building a radial progress component with tailwind

tags: typescript, svelte, tailwindcss

I’ve been toying around with building a task and time mangament app recently. I’m using it as a way to get into learning a bit more about Svelte(Kit) and Tauri. I’ve also increasingly enjoyed using Tailwind and shadcn/ui for styling so I was quite happy to see that the project is being ported to other UI frameworks, including svelte’s port. However, something that is missing from both the svelte port and the original project are radial progress components. One great example I found was daisyUI’s implementation. I didn’t however want to add a new dependency just for a single component. So I decided to try my hand at building my own.

SVGs

One thing that there just is no way around is that this will involve working with SVGs. I’ve always liked the idea of working with SVGs because they are a compact and small way to bundle graphics and images, but I’ve not had a good reason/excuse to dig into them properly (not counting grappeling with D3). For this components (thankfully) we won’ have to dig intot the d property, but we will look at some SVG specific tags, namely circle and text.

The component

First of all I guess it would be helpful to showcase what the component will look like. Here it is:

Radial progress.

This component consists of the following structure:

Circle

Let’s start with an overview of the circle tag and what the attributes are that we need to look into:

cx and cy

These attributes are pretty basic they dictate the respective axis coordinates of the svg. So if you think of them as coordinates on a traditional grid, it means that these coordinates dictate the point where the centre of the circle will be rendered they won’t affect the shape of the circle.

stroke-width

The stroke width simply determines how thick the line that draws the circumference is.

stoke-linecap

This property controlls how the beginning and end of the line around our circle get drawn, in our example we have these nice rounded parts.

fill

With this property we can set the color of the circle, but since we only care about the outline here we can set it to be transparent.

r

The radius. Pretty self explanatory.

stroke-dasharray

This is where things become not so obvious. This attribute is basically where much of the magic happens. It is used to determine how much of the circle’s circumference should be shown as progressed or elapsed. It works in tandem with the stroke-dashoffset property.

stroke-dashoffset

This property is what we use to animate the progress around the circle by shifting the line (from the stroke-dasharray) around the circle. This gets done by setting an offset variable from the full circumference down to 0, the full circumference representing no offset, so 0 seconds of elapsed time, and 0 representing no offset so the maximum time (it’s a bit counter intuitive I know).

Other properties

We will also need to calculate some other things that are not directly tied to the svg elements:

With these values we can build our full component:

<script lang="ts">
  export let elapsed: number;
  export let totalMinutes = 1;

  const full = totalMinutes * 60;
  const radius = 40;
  const circumference = 2 * Math.PI * radius;

  $: dashOffset = circumference * (1 - elapsed / full);
  $: minutes = elapsed / 60;
  $: seconds = elapsed % 60;
</script>

<div class="h-40 w-40">
  <svg class="h-full w-full" viewBox="0 0 100 100">
    <circle
      class="stroke-current text-gray-200 dark:text-gray-700"
      stroke-width="10"
      cx="50"
      cy="50"
      r={radius}
      fill="transparent"
    ></circle>
    <circle
      class="progress-ring stroke-current text-emerald-500 dark:text-emerald-400"
      stroke-width="10"
      stroke-linecap="round"
      cx="50"
      cy="50"
      r={radius}
      fill="transparent"
      stroke-dasharray={`${circumference} ${circumference}`}
      stroke-dashoffset={dashOffset}
    ></circle>
    <text
      class="dark:fill-white"
      x="50"
      y="50"
      font-size="12"
      text-anchor="middle"
      alignment-baseline="middle"
    >
      {Math.floor(minutes)}:{seconds < 10 ? `0${seconds}` : seconds}
    </text>
  </svg>
</div>

<style>
  .progress-ring {
    transition: stroke-dashoffset 0.35s;
    transform: rotate(-90deg);
    transform-origin: 50% 50%;
  }
</style>

Note: I added a custom class to animate the stroke-dashoffset, I know that Svelte has some nice built-in functionality around animations, but I’m not yet too familiar with it so I decided to copy and paste do it this way. For the rest of the styles I the default tailwind classes.

Resources