Custom Progress Element Using Anchor Positioning & Scroll-Driven Animations
Custom Progress Element Using Anchor Positioning & Scroll-Driven Animations êŽë š
In a previous article, we made a cool CSS-only range slider (t_afif
) powered by anchor positioning and scroll-driven animations. Using minimal HTML and a few CSS tricks we created something that would have required a lot of JavaScript if we built it 2 years ago.
In this article, we will do the same with the <progress>
 element and try to make it as cool as the range slider above.
Enough suspense! Here is a demo of what we are making (itâs animated, so hit Rerun if you missed it).
Cool, right? Donât search for the hidden JavaScript code because there is none. As for the HTML, itâs nothing but the <progress>
 element. This leaves us with complex CSS, which is admittedly a bit hard to decipher. But thatâs what weâre here for, so letâs dissect it!
Note
At the time of writing, only Chrome (and Edge) have the full support of the features we will be using.
I highly recommend you read the previous article before this one. Itâs not mandatory but I will be reusing many CSS tricks so this one will be easier to understand if you already know some of the tricks.
The Initial Configuration
We said that the HTML is as simple as the <progress>
 element, which is true, but itâs more complex because this native element has an internal structure that is browser-specific. Itâs one of those situations where we need to use different vendor prefixes and repeat the style more than once.
HTML Structure
Here is the structure when using Chrome, Safari, and Edge:
<progress>
<div pseudo="-webkit-progress-inner-element">
<div pseudo="-webkit-progress-bar">
<div pseudo="-webkit-progress-value"></div>
</div>
</div>
<progress>
And the one when using Firefox:
<progress>
<div pseudo="-moz-progress-bar"></div>
</progress>
Note
For the sake of simplicity, I will only consider the first structure in this article. When Firefox has better support for the features, I will update the article.
CSS Structure
Letâs start with some basic styling.
progress {
width: 200px;
height: 40px;
appearance: none;
}
progress::-webkit-progress-value {
background: #7AB317;
}
Nothing fancy so far. Letâs disable the default appearance, add some dimension, and color the progress. Itâs the same color for all, but later we will have custom colors.
Thatâs it for the initial configuration, letâs move to the interesting parts!
Adding The Tooltip
To create the tooltip I will rely on the ::before
 pseudo-element (or the ::after
 if you want) and I will pick the code of shape from my online collection. I will be using #5 and #6 but you have up to 100 choices!
progress {
position: relative;
}
progress::before {
position: absolute;
content: "00%";
/*
the code of the tooltip shape
copied from the generator
*/
}
Anchor Positioning
Now we have to position the tooltip correctly and here enter Anchor Positioning. This is probably the easiest part and here is the code:
progress::-webkit-progress-value {
anchor-name: --progress;
}
progress::before {
position: absolute;
position-anchor: --progress;
position-area: top right;
}
progress.bottom::before {
position-area: bottom right;
}
Even if you are unfamiliar with the feature, the code should be self-explanatory. The progress value is the anchor, and the pseudo-element is relatively positioned to that anchor. Then we define the position to be top right
 (or bottom right
)
The result so far:
Still not perfect but we can already see that the tooltip is following the progression. We need to rectify the position to make sure the tail is aligned with the corner. A simple translation can fix this:
progress::-webkit-progress-value {
anchor-name: --progress;
}
progress::before {
position-anchor: --progress;
position-area: top right;
/* --h is the variable that controls the height of the tail */
translate: -50% calc(-1.2*var(--h));
}
progress.bottom::before {
position-area: bottom right;
translate: -50% calc(1.2*var(--h));
}
The logic is similar to the translation you combine with left: 0
 or top: 0
 to center an element.
Scoping
I would like to note that using position: relative
 is important here. If you remove it, all the tooltips will be above each other considering the last progress element. This is because I am using the same anchor-name
 and position: relative
 will limit the scope of the anchors. It will make sure each anchor is only available to its progress element.
Another property that allows you to control the scope; it is anchor-scope
. Instead of position: relative
 you can do the following:
progress {
anchor-scope: all;
}
Scoping is probably the issue you will face the most when working with multiple anchors so donât forget about it.
Getting The Progress Value
You probably wonder what kind of CSS magic allows me to get the progress value. The magic is called Scroll-Driven Animations. This is the trickiest part because I will be using a feature that is designed to create cool animations on scroll but in this case, has nothing to do with scrolling and isnât being used to animate. Weird right?
Like with the range slider, I will rely on âview progress timelineâ. We can track the position of an element (the subject) inside a container (the scroller). With the range slider, we had the thumb that we can slide/move with the mouse and here we have the progress value.
Note
But the progress value is static, it doesnât move. How can we track the position of a fixed element!?
It doesnât move but it has a different size based on the progression (more precisely a different width) and this is enough to make it have a different position each time. I know itâs a bit confusing so letâs make a figure.

We have three progress elements with different progression. Considering the structure we saw previously, the progress value is the green element (the ::-webkit-progress-value
) having a width relative to the main element (the <progress>
). In all the cases, the progress value is always placed at the left which means the distance between its right edge and the right edge of the main element is variable.
That distance is the key here because it can be interpreted as a movement. Itâs like at 100%
 of progression the progress value is at right: 0
 and if we decrease the progression, it moves to the left until it reaches right: 100%
 at 0%
 of progression. We can express this using Scroll-Driven animations and convert that distance/movement into a value!
@property --x {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
progress {
animation: x linear;
animation-timeline: --progress;
timeline-scope: --progress;
animation-range: entry 100% exit 100%;
}
@keyframes x {
0% {--x: 100}
100% {--x: 0 }
}
progress::-webkit-progress-bar {
overflow: auto;
}
progress::-webkit-progress-value {
view-timeline: --progress inline;
}
We first define the subject by applying view-timeline
 to the progress value. We have a horizontal movement so we consider the inline axis. Then, we define the scroller by adding overflow: auto
 (or overflow: hidden
).
Note
Why use the ::-webkit-progress-bar
 instead of the progress
?
Technically, both are the same since both have the same width and behave as a container for the progress value (the subject) but remember the tooltip element which is the ::before
 pseudo-element. If we apply overflow to progress
, we will hide it.
After that, we define a linear animation that animates an integer variable from 100
 to 0
. Then we use animation-timeline
 to link the animation with the view-timeline we defined on the subject. The last piece of the puzzle is the use of animation-range
 which is the trickiest part so here is a figure to understand better.

MDN (developer.mozilla.org
)
From the MDN page, we can read:
entry
 Represents the range of a named view progress timeline from the point where the subject element first starts to enter the scroll port (0% progress), to the point where it has completely entered the scroll port (100%).
When we have a 100% progression, the progress value is placed at the right and is completely visible so we can consider âit has completely entered the scroll port (100%)â hence the use of entry 100%
.
exit
 Represents the range of a named view progress timeline from the point where the subject element first starts to exit the scroll port (0% progress), to the point where it has completely exited the scroll port (100%).
When we have a 0% progression, the progress value has a width equal to 0 so both their right and left edges are touching the left edge of the scroller so we can consider âit has completely exited the scroll port (100%).â hence the use of exit 100%
.
This means that when we have a 100% progression, the animation is at 0%, and --x
 is equal to 100. When we have a 0% progression the animation is at 100% and --x
 is equal to 0. In other words, --x
 will contain the progress value we want!
If you are a bit lost, donât worry. We are dealing with a new feature and new concepts so it requires a lot of practice to get used to them. For this reason, I invite you to read the previous article so you have more examples to study. I also went a bit fast here because I already explained a lot of stuff there (like the use of timeline-scope
).
Finally, we show the value within the pseudo element using a counter.
progress::before {
content: counter(val) "%";
counter-reset: val var(--x);
}
Letâs improve the coloration now. We can use the value of --x
 combined with color-mix()
 to create a dynamic coloration.
progress {
--_c: color-mix(in hsl, #E80E0D, #7AB317 calc(1% * var(--x)));
}
When --x
 is equal to 0
 we get color-mix(in hsl,#E80E0D,#7AB317 0%)
 and the first color is used. When --x
 is equal to 100
 we get color-mix(in hsl,#E80E0D,#7AB317 100%)
 and the second color is used. When we have a value between 0
 and 100
 we get a mix of both colors and that mix will depend on the progression!
The color is stored within a variable --_c
 so we can easily use it in different places. In our case, it will color the tooltip and the progress value.
Our progress element is now perfect!
Take the time to digest what you have learned so far before moving to the next section. Consider this as a checkpoint because we have done the important parts. What comes next is some fancy animations and another example for you to study as homework.
Adding The Animation
Here is the demo I shared in the introduction to remind you about the animation we are making:
I had an idea to animate the width of the progress value for 0 to its defined width. The tooltip is anchored to the progress value and --x
 depends on that width so it should be easy. Unfortunately, It doesnât work. For some reason, I cannot apply an animation to the progress value. Itâs probably due to the essential nature of the element.
Details
Here is a simplified demo illustrating what I tried and didnât work. Maybe some of you can find out whatâs wrong.
To overcome this limitation, I will define a new animation within the main element as follows:
@property --y {
syntax: '<number>';
inherits: true;
initial-value: 1;
}
progress {
animation: y 2s .5s both;
}
@keyframes y {
0% {--y: 0}
100% {--y: 1}
}
Then I will use the --y
 variable within the properties that need to animate.
I will start with the progress value where I will create a gradient animation instead of a simple coloration. I will update the below:
progress::-webkit-progress-value {
background: var(--_c);
}
With the following:
progress::-webkit-progress-value {
background:
conic-gradient(var(--_c) 0 0)
0/calc(var(--y)*100%) 100% no-repeat;
}
When --y
 will animate, the width of the gradient will also animate from 0%
 to 100%
 creating the first animation
If you are wondering whatâs going on with that gradient syntax, check this âHow to correctly define a one-color gradientâ
Now, we need to do the same with the tooltip position. We update the following:
progress::before {
position-area: top right;
}
With
progress::before {
position-area: top center;
justify-self: unsafe start;
left: calc(100% * var(--y));
}
We need the tooltip to slide the whole progress value so we have to consider a new position area, which is top center
. Then, the left
 property will animate from 0%
 to 100%
.

The use of position-area: top center
 will apply a default alignment for the tooltip that we need to override to be able to use left
. Thatâs the purpose of justify-self: start
.
As for the unsafe
 keyword, itâs related to a quirk you will face at least once when working with anchor positioning. In the specification, you can read:
CSS Anchor Positioning - W3 (w3.org
)
If the box overflows its inset-modified containing block, but would still fit within its original containing block, by default it will âshiftâ to stay within its original containing block, even if that violates its normal alignment.
To make it easy, there is a mechanism that may change the elementâs position to keep it within specific boundaries. This can be useful in some cases but not here thatâs why I am using unsafe
 to disable that behavior. You can try removing that value and see what is happening.
We are almost done. We are missing the traction effect and the value animation. They are the easiest part:
progress::before {
content: counter(val) "%";
counter-reset: val calc(var(--y) * var(--x));
animation: rotate 2s .5s both cubic-bezier(.18,.4,.8,1.9);
}
@keyframes rotate {
50% { rotate: calc(var(--x) * -.2deg) }
}
Inside the counter, we use calc(var(--y) * var(--x))
 instead of var(--x)
 to animate the value and we consider another animation to animate the rotate
 property based on the --x
 value.
All the tooltips will spend the same amount of time to travel different distances so to have a realistic traction effect the rotation needs to get bigger if the distance is bigger (if the value of progress is bigger) thatâs why rather than using a fixed angle value, I am using a dynamic value that depends on --x
.
Itâs probably very subtle but if you run the demo many times and look closely you will notice the difference. The use of cubic-bezier
 is also important because it adds that braking effect at the end.
We did it! A cool CSS-only effect using only the <progress>
 element.
One More Example: Circular Progress Elements
Donât leave yet! Itâs time for your homework. Here is another demo where I transform the progress element into a circular one. Itâs your turn to dissect the code and try to understand whatâs happening. If you want a real challenge, try to build it alone before checking my code!
And here is the version with the animation where I am simply reusing the same techniques detailed previously.
Conclusion
I hope you enjoyed this CSS experimentation. It was a good exercise and we explored a lot of modern features. You will probably not use these components in a real project but you will for sure need some of the CSS tricks you have learned.