Use CSS+SVG to create an elegant circular progress bar

Get straight to the point

First, the final renderings: (Demo portal)

GIF 2023-10-17 20-47-27.gif

The progress, size, ring width and color can all be controlled very conveniently.

Core Principles:

Using two overlapping rings, the progress is expressed by controlling the arc length of the upper ring, while the lower ring serves as an auxiliary to present the remaining part of the ring progress bar.

Core knowledge points:

  • SVG circle stroke-dasharray
  • Arc length formula l = πrα/180°
  • CSS variables
  • CSS counter

The specific implementation process is shared below.

Implementing a ring

To implement a ring, there are a variety of techniques to choose from, including Canvas, SVG, and even a combination of CSS + HTML. In this article, we use the SVG solution. Firstly, because SVG has a rich API and very powerful graphic expression. Secondly, SVG can be used seamlessly with CSS to achieve more powerful functions.

Use the circle tag in SVG to draw a 200px diameter circle without any stress:

<svg width="400" height="400">
  <circle
    cx="200"
    cy="200"
    r="100"
    fill="none"
    stroke="blue"
    stroke-width="10" />
</svg>

image-20231005184007033.png

Let’s adjust the code again to make the donut graphic fit perfectly into the 200×200 size of the SVG container:

<svg width="200" height="200">
  <circle
    cx="100"
    cy="100"
    r="95"
    fill="none"
    stroke="blue"
    stroke-width="10" />
</svg>

Tips: To perfectly fit the container size, we can set the radius r to half the container width minus the stroke-width size in half, this will ensure that the ring does not get clipped by the container due to overflow. Here r = 200 / 2 – 10 / 2, that is (200 – 10) / 2 = 95

image-20230929174222983.png

Then use the same method to draw another ring as an auxiliary ring. For visual effect, the progress ring should be on top, so in the code, the label of the progress ring should be placed behind the auxiliary ring. The completed code is as follows:

<svg width="200" height="200">
  <!-- Auxiliary ring -->
  <circle
    cx="100"
    cy="100"
    r="95"
    fill="none"
    stroke="#ccc"
    stroke-width="10" />
  
  <!-- Progress Circle -->
  <circle
    cx="100"
    cy="100"
    r="95"
    fill="none"
    stroke="blue"
    stroke-width="10" />
</svg>

It can be found that there are many repeated attributes in the code. In order to make the code more concise and efficient, we can use CSS to extract these repeated parts and declare them uniformly, making the code more “dry” (DRY):

.progress-circle {<!-- -->
  width: 200px;
  height: 200px;
}

.progress-circle > circle {<!-- -->
  cx: 100px;
  cy: 100px;
  r: 95px;
  fill: none;
  stroke-width: 10px;
}
<svg class="progress-circle">
  <circle stroke="#ccc" />
  <circle stroke="blue" />
</svg>

Achieve ring progress

The upper and lower rings are ready. The focus now is how to realize the progress on the upper ring. This problem can be divided into two key points:

  1. How to realize the arc length of a circle?
  2. How to convert progress percentage to arc length of a circle?

To solve the first problem, we need to use the stroke-dasharray attribute in SVG. This is an attribute used to control the density of the dotted lines in the path. Its value is a set of dashes describing the dotted lines. A sequence of lengths of gaps between lines and spaces. For example, if you set stroke-dasharray="5 2", the path will alternate between a 5-pixel dash and a 2-pixel white space, where the first number controls the short stroke. The length of the stroke, and the second number controls the length of the white space.

The parameter value of stroke-dasharray also supports multiple arrays. For details, see the MDN documentation.

Obviously, here we need to control the length of the arc (that is, the length of the dash in the dotted line) to show progress. However, since the dashes in the dotted line are multiple and repeated, simply changing the length of the dashes cannot meet our needs. The specific situation is as shown in the figure below:

GIF 2023-9-30 12-01-51.gif

According to the requirements, we only need a section of the arc of the ring. So how to achieve it? After many attempts, we found that when changing the length of the white space in the dotted line (i.e. the second number of stroke-dasharray), when this length exceeds the circumference of the ring, the visual effect of the ring will be Now we have an independent arc. At this time, we can change the length of the arc by adjusting the first number, thereby solving the first problem above.

GIF 2023-9-30 11-27-11.gif

The second question above seems to be more difficult for the time being, because it is difficult for us to see how long the arc length corresponds to the progress between 0% and 100%. We continue to explore and look for patterns.

According to requirements, when the progress percentage is 100%, the arc of the progress bar should appear as a ring. At this time, the included angle of the arc is 360°. When the progress percentage is 50%, the arc is a semicircle, and the included angle is 180°. The reverse is also true: 180° means 50% and 360° means 100%. It can be seen that there is an equal relationship between the progress percentage and the angle. At the same time, according to the arc length formula l = πrα/180°, the arc length can be calculated by adding the angle. So far, “progress percentage – angle” – Arc length” The rules of the three will be clear, and the idea will be clear soon.

l = πrα/180°

Where l represents arc length, π is pi, r represents radius, and α represents angle.

GIF 2023-9-30 15-38-19.gif

In actual use, we use percentages to control the arc length instead of angles, so next we associate the progress percentage with the arc length.

We change the arc length formula and multiply the numerator and denominator by 2 at the same time, then we have:

l = 2πrα/180°*2, that is, l = 2πr * α/360°.

At the same time, according to the previous information, there is an equivalence relationship between the progress percentage and the angle (360° is equal to 100%), so we can get:

l = 2πr * p/100, where p is the current progress percentage.

Now, we can calculate the corresponding arc length based on the progress percentage.

GIF 2023-9-30 16-06-32.gif

Optimization details

The circular progress bar usually starts from the 12 o’clock direction, and the default in SVG is the 3 o’clock direction as the starting point, so we give it a -90° deflection for correction:

.progress-circle {<!-- -->
  ...
  transform: rotate(-90deg);
}

image-20230930161808711.png

In addition, we found that the endpoints of the arc were too stiff, so it would be a good idea to modify them with a rounded corner effect. Here we use the attribute stroke-linecap in SVG:

.progress-circle > circle {<!-- -->
  ...
  stroke-linecap: round;
}

image-20230930162342907.png

Of course, animation transition effects can also be arranged to make the changes in the progress bar smoother:

.progress-circle > circle {<!-- -->
  ...
  transition: stroke-dasharray 0.4s linear, stroke .3s;
}

Componentization

Although the effect of the circular progress bar has been implemented, it is still difficult to reuse the above code alone. The width, height, radius, color, etc. of the circular progress bar are all hard-coded. In addition, the value of stroke-dasharray must be calculated by JS and then assigned to element. What about the promised pure CSS implementation?

To solve these problems, we need to componentize the circular progress bar.

First define some CSS variables that will be used globally by the component:

/* Container */
.progress-circle {<!-- -->
  --percent: 0; /* Percent */
  --size: 180px; /* size */
  --border-width: 15px; /* Ring width (thickness) */
  --color: #7856d7; /* Main color */
  --inactive-color: #ccc; /* Auxiliary color */
}

Then use calc to change the hard-coded value to dynamic calculation based on CSS variables:

/* Container */
.progress-circle {<!-- -->
  width: var(--size);
  height: var(--size);
  transform: rotate(-90deg);
  border-radius: 50%;
}

/* Progress bar ring graphic */
.progress-circle > circle {<!-- -->
  cx: calc(var(--size) / 2);
  cy: calc(var(--size) / 2);
  r: calc((var(--size) - var(--border-width)) / 2);
  fill: none;
  stroke-width: var(--border-width);
  stroke-linecap: round;
  transition: stroke-dasharray 0.4s linear, stroke .3s;
}

stroke-dasharray in SVG is also adjusted to dynamically calculate based on CSS variables:

<svg class="progress-circle">
  <circle stroke="var(--inactive-color)" />
  <circle stroke="var(--color)"
    style="stroke-dasharray: calc(
      2 * 3.1415 * (var(--size) - var(--border-width)) / 2 *
      (var(--percent) / 100)
    ), 1000"
  />
</svg>

In this way, we can directly control the percentage display of the progress bar by changing the --percent variable of the parent container.

GIF 2023-9-30 17-13-33.gif

In the same way, other internal variables can also be changed to easily control the appearance and size of the component. For example, you can dynamically adjust the progress bar color based on the threshold:

function changeProgress(percent) {<!-- -->
  progressEl.style.setProperty('--percent', percent);

  [
    {<!-- --> value: 90, color: '#7c5' },
    {<!-- --> value: 70, color: '#65c' },
    {<!-- --> value: 50, color: '#fc3' },
    {<!-- --> value: 0, color: '#f66' }
  ].find(it => {<!-- -->
    if (percent >= it.value) {<!-- -->
      progressEl.style.setProperty('--color', it.color);
      return true;
    }
  });
}

GIF 2023-9-29 16-10-31.gif

Show percentage text

You can display percentage text in the center of the circular progress bar to enhance the visual effect. With the previous preparation, percentage text display can be easily achieved by using pseudo elements + CSS counters.

Since pseudo-elements are not supported in SVG, we add a layer of HTML tags as the main container:

<div class="progress-circle">
  <svg>
    ...
  </svg>
</div>

Then add a pseudo element to the main container, position it in the center, and then use the CSS counter to receive the value of the --percent variable:

/* Percent text */
.progress-circle::before {<!-- -->
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  counter-reset: progress var(--percent);
  content: counter(progress) '%';
  white-space: nowrap;
  font-size: 18px;
}

The effect is as follows:

GIF 2023-9-30 17-44-47.gif

Improve boundary scenes

Above, stroke-linecap: round is added to the circle to make the endpoints of the stroke rounded, but it leads to a small problem: when the actual progress is 0%, due to the existence of endpoint rounding, The visual effect of the progress bar is obviously greater than 0%, as shown in the following figure:

image-20231005115314590.png

Inspired by the article “Pure CSS to implement automatic display of unread messages exceeding 100 as 99+”, we add an opacity attribute with a value of --percent to the progress ring, that is This problem can be solved by:

<div class="progress-circle">
  <svg>
    <circle ... />
    <circle class="progress-value" ... />
  </svg>
</div>
.progress-value {<!-- -->opacity: var(--percent);}

When the value of --percent is 0, and opacity is 0, the progress ring is completely transparent and does not display; when --percent When the value of is greater than 0, the value of opacity is treated as 1, and the progress ring is displayed normally.

GIF 2023-10-5 12-10-25.gif

Extension: Dashboard-style progress bar

There is also a variant of the circular progress bar – the dashboard-style progress bar. As the name suggests, it displays progress in the form of a dashboard, which has the advantages of strong visual appeal and easy to understand. The image below is an example of a dashboard-style progress bar from AntDesign:

image-20231017210252819.png

Based on the previous knowledge, let’s strike while the iron is hot and make a dashboard-style progress bar.

Generate gap

Visually, the biggest difference between the dashboard-type progress bar and the ring progress bar is that the former has a blank “gap”, and the gap faces downward, and the overall left and right symmetry.

We can easily think of using the first parameter of stroke-dasharray to generate visible arcs (that is, the part other than the “gap”), and using the second parameter to generate gaps (that is, the “gap” ). Taking a gap with a central angle of 90° as an example, and substituting the arc length formula (l = πrα/180°) into the equation:

<div class="progress-circle">
  <svg>
    <circle stroke="var(--inactive-color)"
            style="stroke-dasharray: calc(3.1415 * var(--r) * (360 - 90) / 180),
                                     calc(3.1415 * var(--r) * 90 / 180)"
    />
  </svg>
</div>

Results as shown below:

image-20231017214115175.png

Determine svg rotation angle

Without any rotation of the container, the 90° notch faces the upper right corner, but what we actually want is to make the notch face downward and make the whole body symmetrical. To achieve this goal, we need to rotate the svg container 135° clockwise. The effect is as follows:

GIF 2023-10-17 21-52-30.gif

In actual scenarios, the size of this “gap” is configurable, so we encapsulate the angle of the gap into the CSS variable of the main container to facilitate subsequent dynamic calculations.

.progress-circle {<!-- -->
  ...
  --gap-degree: 90; /* Gap angle */
}

When the angle of the gap becomes dynamic, how much does the svg container need to rotate to make the opening of the gap face downward and make the whole body symmetrical? Let’s take the 90° angle gap as an example to analyze. From the previous practice, we can know that if you want to make the 90° notch face downward and make the whole body symmetrical, you need to rotate the svg container 135° clockwise. The schematic diagram after rotation is as follows:

135.png

Among them, ∠A = ∠B = 135°. Careful observation shows that the 135° here is equal to the 90° angle between the gap itself plus the 45° angle between ∠A and ∠B. Obviously, when When the gap angle changes, the overlap angle here needs to be calculated dynamically. Combining the above figure, we can easily see that there is an equation here:

2 × overlap angle + gap angle = 180°

Then there is:

Coincidence angle = (180° - gap angle) / 2

Combined with CSS variables, we can determine the rotation angle of the svg container. The code is as follows:

.progress-circle > svg {<!-- -->
  ...
  transform: rotate(
    calc((var(--gap-degree) + (180 - var(--gap-degree)) / 2) * 1deg)
  );
}

In this way, our progress bar can adapt to different gap angles:

GIF 2023-10-18 9-40-40.gif

Correct progress conversion

When we tested the dashboard-style progress bar in the form of a circular progress bar, we found that there was a problem with the progress display of the dashboard-style progress bar – the actual progress percentage and the progress display did not match, as shown in the following figure:

GIF 2023-10-18 9-51-02.gif

In fact, it’s not difficult to understand. Let’s first look at the code part of the existing progress ring:

<circle stroke="var(--color)"
  class="progress-value"
  style="stroke-dasharray: calc(2 * 3.1415 * var(--r) * (var(--percent) / 100)), 1000"
/>

In the circular progress bar, var(--percent) / 100 is equivalent to α / 360°. A 360° arc represents 100% progress. But in the dashboard-style progress bar, the corresponding arc after 360° minus the gap angle truly represents 100% progress. Therefore, we have to convert the factor of the gap angle and correct the actual progress display.

Let’s start with this formula: l = 2πr * α/360°

In the circular progress bar, 50% of the progress corresponds to the arc angle of 180°. Substituting into the formula is:

l = 2πr * 180 / 360

For example, if you switch to a dashboard-style progress bar, when the angle between the gaps is 90°, the denominator here is 360° - 90° = 270°. Primary school mathematics teaches us: when the numerator and denominator change in equal proportions, their value does not change, so we multiply the numerator by the value of the denominator change, then we have:

l = 2πr * 180 * (270 / 360) / 270

Further decomposition can be obtained: l = πr * 180 * (270 / 180) / 270

We replace the arcs except the gap angle with CSS variables --active-degree: calc(360 - var(--gap-degree)); and write them into the code of the progress circle :

<circle stroke="var(--color)"
  class="progress-value"
  style="stroke-dasharray: calc(3.1415 * var(--r) * 180 * var(--active-degree) / 180 / var(--active-degree)), 1000"
/>

Where (180 * var(--active-degree) / 180) / var(--active-degree) is equivalent to (var(--percent) * var(--active -degree) / 180) / 100, the answer is already obvious:

<circle stroke="var(--color)"
  class="progress-value"
  style="stroke-dasharray: calc(3.1415 * var(--r) * var(--percent) * var(--active-degree) / 180 / 100), 1000"
/>

Final effect: (Demo portal)

GIF 2023-10-18 10-47-20.gif

Finally

Using CSS and SVG technology, we successfully implemented an elegant circular progress bar. This progress bar can not only display progress, but also adjust the style through CSS variables to meet different needs. I hope this sharing will be helpful to everyone in using CSS and SVG. I also hope that everyone can continue to explore and innovate in practice and create more practical and beautiful works.

About OpenTiny

Picture

OpenTiny is an enterprise-level Web front-end development solution that provides a cross-end and cross-frame UI component library, adapts to multiple terminals such as PC/mobile terminals, supports Vue2/Vue3/Angular multi-technology stacks, and has a flexible and scalable low-code engine. , including theme configuration system/middle and backend templates/CLI command line and other rich efficiency improvement tools, which can help developers develop web applications efficiently.

Core Highlights:

  • Cross-end and cross-framework: Using the Renderless component-less design architecture, a set of code is implemented to support Vue2/Vue3, PC/Mobile side at the same time, and supports function-level logic customization and full template replacement, with good flexibility and strong secondary development capabilities. .

  • Rich components: There are 100+ components on the PC side and 30+ components on the mobile side, including high-frequency components Table, Tree, Select, etc., with built-in virtual scrolling to ensure a smooth experience in big data scenarios. In addition to common components in the industry, we also Provides some unique features components, such as: Split panel splitter, IpAddress IP address input box, Calendar calendar, Crop picture cropping, etc.

  • Low-code engine: The low-code engine enables developers to customize the low-code platform. It is the base of the low-code platform and provides basic capabilities such as visual page building. It can be combined online or secondary developed by downloading the source code to customize your own low-code platform in real time. Suitable for low-code platform development in multiple scenarios, such as: resource orchestration, server-side rendering, model driver, mobile terminal, large screen terminal, page layout, etc.

  • Configuration components: The components support both template and configuration methods, and are suitable for low-code platforms. At present, the team has integrated OpenTiny into the internal low-code platform and made a lot of optimizations for the low-code platform.

  • Complete surrounding ecosystem: Provides TinyNG component library based on Angular + TypeScript, provides TinyPro mid- and back-end templates containing 10+ practical functions and 20+ typical pages, provides TinyCLI engineering tools covering the entire front-end development process, and provides powerful online theme configuration PlatformTinyTheme.

Welcome to the OpenTiny open source community. Add WeChat assistant: opentiny-official to participate in the exchange of front-end technology~

OpenTiny official website: https://opentiny.design/

OpenTiny code repository: https://github.com/opentiny/

TinyEngine source code: https://github.com/opentiny/tiny-engine

Welcome to the code repository StarTinyEngine, TinyVue, TinyNG, TinyCLI~

If you also want to co-build, you can enter the code repository, find the good first issue tag, and participate in open source contributions together~

Recommended articles from previous issues

Picture

  • OpenTiny Vue 3.10.0 version released: Component Demo supports Composition writing method, adding 4 new components
  • Front-end Vuer, please keep this “Vue Component Unit Testing” guide
  • OpenTiny front-end component library is officially open source! Facing the future, born for developers
  • From self-research to open source TinyVue component library
  • I want to be open source: submit my first PR
  • Essential UI components one – basic knowledge of components
  • [Huawei Connected Conference 2023 high-energy and exciting news] OpenTiny Engine low-code engine will be open source soon