Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Fri, 14 Nov 2025 16:27:19 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Custom progress element using the attr() function https://frontendmasters.com/blog/custom-progress-element-using-the-attr-function/ https://frontendmasters.com/blog/custom-progress-element-using-the-attr-function/#respond Wed, 09 Apr 2025 18:39:02 +0000 https://frontendmasters.com/blog/?p=5537 is easier.]]> In a previous article, we combined two modern CSS features (anchor positioning and scroll-driven animations) to style the <progress> element without extra markup and create a cool component. Here’s that demo:

Anchor positioning was used to correctly place the tooltip shape while scroll-driven animations were used to get the progress value and show it inside the tooltip. Getting the value was the trickiest part of the experimentation. I invite you to read the previous article if you want to understand how scroll-driven animations helps us do it.

In this article, we will see an easier way to get our hands on the current value and explore another example of progress element.

At the time of writing, only Chrome (and Edge) have the full support of the features we will be using.

Article Series

Getting the progress value using attr()

This is the HTML element we are working with:

<progress value="4" max="10"></progress>

Nothing fancy: a progress element where you define the value and max attribute. Then we use the following CSS:

progress[value] {
  --val: attr(value type(<number>));
  --max: attr(max type(<number>),1);

  --x: calc(var(--val)/var(--max)); /* the percentage of progression */
}

We waited for this for too long! It’s finally here!

We can use attr() function not only with the content property but with any property including custom properties! The variable --x will contain the percentage of progression as a unit-less value in the range [0 1]. That’s all — no complex code needed.

We also have the ability to define the types (number, in our case) and specify fallback values. The max attribute is not mandatory so if not specified it will default to 1. Here is the previous demo using this new method instead of scroll-driven animations:

If we omit the tooltip and animation parts (explained in the previous article), the new code to get the value and use it to define the content of the tooltip and the color is a lot easier:

progress {
  --val: attr(value type(<number>));
  --max: attr(max type(<number>),1);

  --x: calc(100*var(--val)/var(--max));
  --_c: color-mix(in hsl,#E80E0D,#7AB317 calc(1%*var(--x)));
}
progress::value {
  background: var(--_c);
}
progress::before {
  content: counter(val) "%";
  counter-reset: val var(--x);
  background: var(--_c);
}

Should we forget about the “complex” scroll-driven animations method?

Nah — it can still be useful. Using attr() is the best method for this case and probably other cases but scroll-driven animations has one advantage that can be super handy: It can make the progress value available everywhere on the page.

I won’t get into the detail (as to not repeat the previous article) but it has to do with the scope of the timeline. Here is an example where I am showing the progress value within a random element on the page.

The animation is defined on the html element (the uppermost element) which means all the elements will have access to the --x variable.

If your goal is to get the progress value and style the element itself then using attr() should be enough but if you want to make the value available to other elements on the page then scroll-driven animations is the key.

Progress element with dynamic coloration

Now that we have our new way to get the value let’s create a progress element with dynamic coloration. This time, we will not fade between two colors like we did in the previous demo but the color will change based on the value.

A demo worth a thousand words:

As you can see, we have 3 different colors (red, orange and green) each one applied when the value is within a specific range. We have a kind of conditional logic that we can implement using various techniques.

Using multiple gradients

I will rely on the fact that a gradient with a size equal to 0 will be hidden so if we stack multiple gradients and control their visibility we can control which color is visible.

progress[value] {
  --val: attr(value type(<number>));
  --max: attr(max type(<number>),1);
  --_p: calc(100%*var(--val)/var(--max)); /* the percentage of progression */
}
progress[value]::-webkit-progress-value {
   background: 
    /* if (p < 30%) "red" */
    conic-gradient(red    0 0) 0/max(0%,30% - var(--_p)) 1%,
    /* else if (p < 60%) "orange" */
    conic-gradient(orange 0 0) 0/max(0%,60% - var(--_p)) 1%,
    /* else "green" */
    green;
}

We have two single-color gradients (red and orange) and a background-color (green). If, for example, the progression is equal to 20%, the first gradient will have a size equal to 10% 1% (visible) and the second gradient will have a size equal 40% 1% (visible). Both are visible but you will only see the top layer so the color is red. If the progression is equal to 70%, both gradients will have a size equal to 0% 1% (invisible) and the background-color will be visible: the color is green.

Clever, right? We can easily scale this technique to consider as many colors as you want by adding more gradients. Simply pay attention to the order. The smallest value is for the top layer and we increase it until we reach the bottom layer (the background-color).

Using an array of colors

A while back I wrote an article on how to create and manipulate an array of colors. The idea is to have a variable where you can store the different colors:

--colors: red, blue, green, purple;

Then be able to select the needed color using an index. Here is a demo taken from that article.

This technique is limited to background coloration but it’s enough for our case.

This time, we are not going to define precise values like we did with the previous method but we will only define the number of ranges.

  • If we define N=2, we will have two colors. The first one for the range [0% 50%[ and the second one for the range [50% 100%]
  • If we define N=3, we will have three colors. The first one for [0% 33%[, the second for [33% 66%[ and the last one for [66% 100%]

I think you get the idea and here is a demo with four colors:

The main trick here is to convert the progress value into an index and to do this we can rely on the round() function:

progress[value] {
  --n: 4; /* number of ranges */
  --c: #F04155,#F27435,#7AB317,#0D6759;
  
  --_v: attr(value type(<number>));
  --_m: attr(max type(<number>),1);
  --_i: round(down,100*var(--_v)/var(--_m),100/var(--n)); /* the index */
}

For N=4, we should have 4 indexes (0,1,2,3). The 100*var(--_v)/var(--_m) part is a value in the range [0 100] and 100/var(--n) part is equal to 25. Rounding a value to 25 means it should be a multiplier of 25 so the value will be equal to one of the following: 0, 25, 50, 75, 100. Then if we divide it by 25 we get the indexes.

But we have 5 indexes and not 4.

True, the value 100 alone will create an extra index but we can fix this by clamping the value to the range [0 99]

--_i: round(down,min(99,100*var(--_v)/var(--_m)),100/var(--n));

If the progress is equal to 100, we will use 99 because of the min() and the round will make it equal to 75. For the remaining part, check my other article to understand how I am using a gradient to select a specific color from the array we defined.

progress[value]::-webkit-progress-value {
   background:
     linear-gradient(var(--c)) no-repeat
     0 calc(var(--_i)*var(--n)*1%/(var(--n) - 1))/100% calc(1px*infinity);
}

Using an if() condition

What we have done until now is a conditional logic based on the progress value and CSS has recently introduced inline conditionals using an if() syntax.

The previous code can be written like below:

@property --_i {
  syntax: "<number>";
  inherits: true;
  initial-value: 0; 
}
progress[value] {
  --n: 4; /* number of ranges */
  
  --_v: attr(value type(<number>));
  --_m: attr(max type(<number>),1);
  --_i: calc(var(--n)*round(down,min(99,100*var(--_v)/var(--_m)),100/var(--n))/100); 
}
progress[value]::-webkit-progress-value {
   background: if(
     style(--_i: 0): #F04155;
     style(--_i: 1): #F27435;
     style(--_i: 2): #7AB317;
     style(--_i: 3): #0D6759;
    );
}

The code is self-explanatory and also more intuitive. It’s still too early to adopt this syntax but it’s a good time to know about it.

Using Style Queries

Similar to the if() syntax, we can also rely on style queries and do the following:

@property --_i {
  syntax: "<number>";
  inherits: true;
  initial-value: 0; 
}
progress[value] {
  --n: 4; /* number of ranges */
  
  --_v: attr(value type(<number>));
  --_m: attr(max type(<number>),1);
  --_i: calc(var(--n)*round(down,min(99,100*var(--_v)/var(--_m)),100/var(--n))/100); 
}
progress[value]::-webkit-progress-value {
  @container style(--_i: 0) {background-color: #F04155}
  @container style(--_i: 1) {background-color: #F27435}
  @container style(--_i: 2) {background-color: #7AB317}
  @container style(--_i: 3) {background-color: #0D6759}
}

We will also be able to have a range syntax and the code can be simplified to something like the below:

@property --_i {
  syntax: "<number>";
  inherits: true;
  initial-value: 0; 
}
progress[value] {
  --_v: attr(value type(<number>));
  --_m: attr(max type(<number>),1);
  --_i: calc(var(--_v)/var(--_m)); 
}
progress[value]::-webkit-progress-value {
  background-color: #0D6759;
  @container style(--_i < .75) {background-color: #7AB317}
  @container style(--_i < .5 ) {background-color: #F27435}
  @container style(--_i < .25) {background-color: #F04155}
}

Conclusion

I hope this article and the previous one give you a good overview of what modern CSS looks like. We are far from the era of simply setting color: red and margin: auto. Now, it’s a lot of variables, calculations, conditional logic, and more!

Article Series

]]>
https://frontendmasters.com/blog/custom-progress-element-using-the-attr-function/feed/ 0 5537
Custom Progress Element Using Anchor Positioning & Scroll-Driven Animations https://frontendmasters.com/blog/custom-progress-element-using-anchor-positioning-scroll-driven-animations/ https://frontendmasters.com/blog/custom-progress-element-using-anchor-positioning-scroll-driven-animations/#comments Wed, 13 Nov 2024 16:56:38 +0000 https://frontendmasters.com/blog/?p=4369 In a previous article, we made a cool CSS-only range slider 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.

Article Series

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!

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>

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.

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).

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.

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.

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:

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.

Article Series

]]>
https://frontendmasters.com/blog/custom-progress-element-using-anchor-positioning-scroll-driven-animations/feed/ 2 4369
indeterminate https://frontendmasters.com/blog/indeterminate/ https://frontendmasters.com/blog/indeterminate/#respond Thu, 16 Nov 2023 19:33:23 +0000 http://fem.flywheelsites.com/?p=115 Josh W. Comeau:

A cool thing I always forget: The <progress> tag can be put in an “indeterminate” state by omitting the value attribute. The idea is that we want to signal to the user that something is in progress, but we don’t know how far along it is.

With a value:

Indeterminate:

]]>
https://frontendmasters.com/blog/indeterminate/feed/ 0 115