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 Perfectly Pointed Tooltips: To The Corners https://frontendmasters.com/blog/perfectly-pointed-tooltips-to-the-corners/ https://frontendmasters.com/blog/perfectly-pointed-tooltips-to-the-corners/#respond Mon, 10 Nov 2025 15:04:33 +0000 https://frontendmasters.com/blog/?p=7714 Ready for the last challenge?

We are still creating tooltips that follow their anchors, and this time we will work with new positions and learn new tricks. I will assume you have read and understood the first two parts, as I will skip the things I already explained there. Fair warning, if you haven’t read those first two you might get a little lost.

Article Series

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

As usual, a demo of what we are making:

This time, instead of considering the sides, I am considering the corners. This is another common pattern in the tooltip world. The code structure and the initial configuration remain the same as in the previous examples, so let’s jump straight into the new stuff.

Defining The Positions

If you took the time to explore my interactive demo, you already know the position we will start with:

position-area: top left;

The other positions will logically be top rightbottom left, and bottom right. We already learned that defining explicit positions is not the ideal choice, so let’s flip!

The flipped values are:

position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline; 

The advantage of this configuration is that we are not using flip-start, so we can safely define min-width (or max-height) without issue. The drawback is that adding the tail is complex. It needs to be placed on the corners, and the margin trick won’t work. We need another hack.

Notice how I am using margin instead of inset to control the gap between the tooltip and the anchor. Both are correct, but you will see later why margin is slightly better in my use case.

Adding The Tail

In the previous examples, the logic is to draw a shape with all the tails, then hide the non-needed parts. The tail has the same color as the tooltip and is placed behind its content, so we can only see what is outside the boundary of the tooltip.

This time, I will use a slightly different idea. I am still drawing a shape with all the tails, but the hiding technique will be different.

First, we place the pseudo-element of the tooltip above the anchor. Not on the top of it, but both will overlap each other.

#tooltip::before {
  content: "";
  position: fixed;
  position-anchor: --anchor;
  position-area: center;
  width:  anchor-size(width);
  height: anchor-size(height);
}

I am using a fixed position to be able to “see” the anchor (we talked about this quirk in the first article). Then, I place the element in the center area, which means above the anchor element (or below it depending on the z-index).

I am introducing a new function, anchor-size(), which is part of the Anchor Positioning API. We saw the anchor() function, which allows us to query the position from an anchor element. anchor-size()does the same but with the sizes. I am using it to make the pseudo-element have the same size as the anchor. It’s like using width: 100% where 100% refers to the anchor.

Nothing fancy so far. We have a square behind the anchor.

Let’s increase the size a little so it also touches the tooltip. We add twice the gap defined by the variable --d plus the value of --s, which controls both the radius and the size of the tooltip.

#tooltip {
  --d: .5em; /* distance between anchor and tooltip */
  --s: .8em; /* tail size & border-radius */ 
}

#tooltip:before {
  width:  calc(anchor-size(width) +  2*(var(--d) + var(--s)));
  height: calc(anchor-size(height) + 2*(var(--d) + var(--s)));
}

It seems we are going nowhere with this idea but, believe me, we are almost there.

Now we sculpt the pseudo-element to have the shape of a tail on each corner, like illustrated below:

Illustration showing a blue square transitioning into a tooltip design with four symmetrical tails around a centered anchor icon.

I am using a somewhat verbose clip-path value to create the final shape but the method used is not particularly important. You can consider gradients, SVG background, the new shape() function, etc. Perhaps you would also like to have a different design for the tails. The main idea is to have four tails around the anchor.

Do you start to see the tricks? We have the correct position for the tails (you can drag the anchor and see the result), but we still have to hide the extra ones.

All we need is to add one line of code to the tooltip:

clip-path: inset(0) margin-box;

I know it’s not very intuitive but the explanation is fairly simple. Even if the pseudo-element is using a fixed position and has lost its relation with the tooltip, it remains part of its content, so clipping the tooltip will also affect the pseudo-element.

In our case, the clip-path will consider the margin box as its reference to create a basic rectangle using inset(0) that will show only what is inside it. In other words, anything outside the margin area is hidden.

Toggle the “debug mode” in the demo below and you will see a black rectangle that illustrates the clip-path area.

Only one tail can fit that rectangle, which is perfect for us!

This trick sounds cool! Can’t we apply it to the previous demo as well?

We can! This series of articles could have been one article detailing this trick that I apply to the three examples, but I wanted to explore different ideas and, more importantly, learn about anchor positioning through many examples. Plus, it’s always good to have various methods to achieve the same result.

What about trying to redo the previous example using this technique? Take it as homework to practice what you have learned through this series. You will find my implementation in the next section.

More Examples

Let’s start with the previous demos using the new technique. As usual, you have the debug mode to see what’s going on behind the scenes.

I will conclude with one final example for you to study. You can also try to implement it before checking my code if you want another challenge.

And a version with a curved tail:

Conclusion

I hope you enjoyed this article series. Our goal was to leverage modern CSS to create common tooltip patterns, while also exploring the powerful Anchor Positioning API. It’s one of those modern features that introduce new mechanisms into the CSS world. We are far from the era where we simply define properties and see a static result. Now we can link different elements across the page, create conditional positioning, define a dynamic behavior that adjusts to each situation, and more!

This feature is only at its Level 1. The Level 2 will introduce even more ideas, one of which is the ability to query the fallback positions and apply a custom CSS. Here is one of the previous demos using this future technique:

The code is probably more verbose, but it feels less hacky and more intuitive. I let you imagine all the possibilities you can do with this technique.

Article Series

]]>
https://frontendmasters.com/blog/perfectly-pointed-tooltips-to-the-corners/feed/ 0 7714
Perfectly Pointed Tooltips: All Four Sides https://frontendmasters.com/blog/perfectly-pointed-tooltips-all-four-sides/ https://frontendmasters.com/blog/perfectly-pointed-tooltips-all-four-sides/#comments Mon, 03 Nov 2025 16:15:35 +0000 https://frontendmasters.com/blog/?p=7543 Time for part two! We’ve got really nice functional positioned tooltips already, but they were mostly concerned with “pointing” up or down and shifting at the edges to avoid overflow. Now we’re going to take it further, considering four positions without shifts.

Article Series

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

Here is a demo of what we are making:

Drag the anchor and see how the tooltip switches between the four positions and how it remains centered relatively to the anchor.

The Initial Configuration

We are going to use the same code structure as in the first part. We start with the tooltip placed above the anchor (the “top”).

<div id='anchor'></div>
<div id='tooltip'></div>
#anchor {
  anchor-name: --anchor;
}
#tooltip {
  --d: 1em; /* distance between tooltip and anchor */

  position: absolute; 
  position-anchor: --anchor;
  position-area: top;
  bottom: var(--d);
}

From here on, things will be different from the previous example.

Defining Multiple Positions

The position-try-fallbacks property allows us to define multiple positions. Let’s try the following:

position-try-fallbacks: bottom, left, right;

Let’s not forget that the placement is related to the containing block, which is the body in our example (illustrated with the dashed border):

We almost have the same behavior as the first example; however if you are close to the right or left edges, you get the new positions. Instead of overflowing, the browser will swap to the right or left position.

Illustration showing a tooltip following an anchor, with a crossed-out example on the left and a correct behavior on the right, displaying the text 'Drag the anchor and I should follow...'

Similar to the first example, the gap disappears when switching to the fallback positions. We know how to fix it! Instead of explicitly defining the positions, we can rely on the “flip” feature.

To move from top to bottom, we use flip-block:

position-try-fallbacks: flip-block, left, right;

From top to left, we use flip-start:

position-try-fallbacks: flip-block, flip-start, right; 

The flip-block value mirrors the position across the horizontal axis, and flip-start does the same across the diagonal. With this value, we can move from top to left and from bottom to right. And logically, we also have a flip-inline that considers the vertical axis to move from left to right.

But how do we move from top to right? We are missing another value, right?

No, we have all the necessary values. To move from top to right, we combine two flips: flip-block to move to the bottom, then flip-start to move to the right:

position-try-fallbacks: flip-block, flip-start, flip-block flip-start;

Or flip-start to move to the left, and then flip-inline to move to the right:

position-try-fallbacks: flip-block, flip-start, flip-start flip-inline;

It should be noted that all the flips consider the initial position defined on the element and not the previous position defined on position-try-fallbacks or the current position. If we first perform a flip-block to move to the bottom, the flip-start of the second position will not consider the bottom position but the top position (the initial one). This can be confusing, especially when you have many positions.

Said differently, the browser will first transform all the flips into positions (considering the initial position) and then pick the suitable one when needed.

Disabling the Shift Behavior

What we have is actually good and might work perfectly for some use-cases, but we’re aiming for slightly more advanced functionality. What we want is to flip to the left or right position as soon as the tooltip touches the edges. We don’t want to have the “shift” behavior. I want the tooltip to remain always centered relatively to the anchor.

Image showing four tooltip positions in relation to an anchor, with text indicating interaction.

For this, we can use:

justify-self: unsafe anchor-center;

What is this strange value!?

After defining the position of an element using position-area we can also control its alignment using justify-self and align-self (or the shorthand place-self). However, we get a default alignment that you rarely need to change.

For position-area: top, the default alignment is equivalent to justify-self: anchor-center and align-self: end.

Don’t we have a center value? Why is it called anchor-center?

The center value exists, but its behavior is different from anchor-center. The center value considers the center of the area, while anchor-center considers the center of the anchor in the relevant axis.

Here is a screenshot taken from my interactive demo, where you can see the difference:

Comparison of element alignment in CSS, showing the difference between centering in the top area versus centering at the anchor point.

In addition to that, anchor-center follows the logic of safe alignment which cause the shift behavior. When there is not enough room for centering, the element will shift to remain within the containing block area. To disable this, we tell the browser to consider an “unsafe” behavior hence the use of:

justify-self: unsafe anchor-center;

Here is a demo with only the top and bottom positions. Notice how the tooltip will overflow from the left and right sides instead of shifting.

And if we add back the left and right positions to the fallbacks, the browser will use them instead of overflowing!

It should be noted that justify-self is also included in the flip. It’s one of those properties that the browser changes when flipping. When the position is top or bottom, it remains justify-self, but when the position is left or right, it becomes align-self. Another reason why it’s better to consider the flip feature instead of explicitly defining a position.

Adding min-width

The position of the tooltip is now good, but in some particular cases, it’s too narrow.

A tooltip with a blue background displaying the text 'Drag the anchor and I should follow...' is positioned above a gray anchor icon.

That’s a logical behavior since the text inside can wrap to make the tooltip fit that position. You probably want to keep that behavior, but in our case, we’d like to add min-width to force it to flip to another position before shrinking too much. It can also be a max-height as well.

Oops, min-width is not preventing wrapping, but it is increasing the height! What?!

Can you guess what the issue is? Think a moment about it.

It’s the flip behavior.

The min-width and all the sizing properties are also affected by the flip. The initial configuration is top, so defining min-width means that when we perform a flip-start to move to the left or the right position, the min-width becomes min-height, which is not good.

So we define min-height instead, when flipped it becomes min-width!

Yes, but the min-height will apply to the top and bottom positions, which is not ideal either.

We can fix this by using custom positions where we define all the properties manually.

#tooltip {
  min-width: 10em;

  position-area: top;
  justify-self: unsafe anchor-center;
  bottom: var(--d);
  position-try-fallbacks: flip-block,--left,--right;
}
@position-try --left {
  position-area: left;
  justify-self: normal;
  align-self: unsafe anchor-center;
  right: var(--d);
}
@position-try --right {
  position-area: right;
  justify-self: normal;
  align-self: unsafe anchor-center;
  left: var(--d);
}

We use @position-try to create a custom position with a given name, and inside it we define all the properties. Instead of using flip-start to set the left position, I define a custom --left position with all the necessary properties to correctly place the tooltip on the left. Same for the right position. In this situation, min-width is preserved for all positions, as we are no longer using flip-start.

It is worth noting that when using a custom position, you need to ensure that you override all the properties of the initial position defined on the element otherwise they still apply. For this reason, I am defining justify-self: normal to override justify-self: unsafe anchor-centernormal being the default value of justify-self.

While this solution works fine, it’s a bit verbose, so I was wondering if we can do better. It turns out we can!

We can combine the flip feature and custom positions to get a shorter code:

#tooltip {
  position-area: top;
  justify-self: unsafe anchor-center;
  bottom: var(--d);
  position-try: flip-block,--size flip-start,--size flip-start flip-inline;
}
@position-try --size {
  min-height: 12em; /* this is min-width! */
}

When we define a custom position with a flip, the browser selects the properties within the custom position, as well as the properties already defined on the element, and then performs the flip. So --size flip-start will flip the properties defined on the element and the one defined in the custom position --sizemin-height becomes a min-width! Clever, right?

But you said we cannot use min-height?

We cannot use it on the main element as it will apply to the top and bottom positions. However, within a custom position, I can select where it applies, and I want it to apply only to the left and right positions. Plus, I don’t need any min-width or min-height constraint when the position is top or bottom.

Now our tooltip position is perfect! Let’s add the tail.

Adding The Tail

First, we create a shape that contains the 4 tails.

Comparison of tooltip shapes demonstrating the transition from a red diamond shape to a blue rounded shape with the text 'Drag the anchor and I should follow...'
#tooltip:before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: calc(-1*var(--d));
  clip-path: polygon(
    calc(50% - var(--s)) var(--d),50% .2em,calc(50% + var(--s)) var(--d),
    calc(100% - var(--d)) calc(50% - var(--s)), calc(100% - .2em) 50%,calc(100% - var(--d)) calc(50% + var(--s)),
    calc(50% + var(--s)) calc(100% - var(--d)),50% calc(100% - .2em),calc(50% - var(--s)) calc(100% - var(--d)),
    var(--d) calc(50% + var(--s)), .2em 50%,var(--d) calc(50% - var(--s))
  );
}

Then we control it using margin on the tooltip element, just as we did in the first part. When the position is top, we add a margin to all the sides except for the bottom one:

margin: var(--d);
margin-bottom: 0;
Comparison of tooltip designs showing a red diamond-shaped tooltip on the left and a blue rectangular tooltip on the right, both displaying the text 'Drag the anchor and I should follow...'.

And for the other sides, we do nothing! The flip will do the job for us.

Toggle the “debug mode” to see how the shape behaves in each position.

Conclusion

We have completed the second part. Now, you should be comfortable working with fallbacks, the flip feature, and custom positions. If you are still struggling, give the article another read. We still have one final challenge, so make sure everything is clear before moving to the next article.

Article Series

]]>
https://frontendmasters.com/blog/perfectly-pointed-tooltips-all-four-sides/feed/ 2 7543
Perfectly Pointed Tooltips: A Foundation https://frontendmasters.com/blog/perfectly-pointed-tooltips-a-foundation/ https://frontendmasters.com/blog/perfectly-pointed-tooltips-a-foundation/#comments Tue, 28 Oct 2025 16:51:29 +0000 https://frontendmasters.com/blog/?p=7514 Tooltips are a classic in web development. You click on an element, and a small bubble appears to display additional details. Behind that simple click, there tends to be JavaScript performing calculations to determine the correct position for the tooltip. Let’s try to place it at the top. Nope, not enough space. Let’s try the bottom instead. It’s also touching the right edge so let’s shift it a bit to the left. There is a lot that can go into making sure a tooltip is placed well without any cutoffs losing information.

In this article, I will show you how to write good JavaScript that covers all the possibilities…

Kidding! We’ll be using CSS and I will show how the modern anchor positioning API can help us with all this. None of the weight and performance concerns of JavaScript here.

Article Series

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

Let’s start with a demo:

Click-and-drag the anchor and see how the tooltip behaves. It will try to position itself in a way to remain visible and avoid any overflow. Cool, right? No JavaScript is used to position the tooltip (except the one for dragging the anchor, which is irrelevant to the trick).

This is made possible thanks to the new Anchor Positioning API and a few other tricks we will dissect together. We will also study more examples, so if you are new to anchor positioning, you are in the right place.

The Initial Configuration

Let’s start with the markup: An anchor element and its tooltip:

<div id='anchor'></div>
<div id='tooltip'></div>

This isn’t interesting HTML, but it does showcase how the anchor and the tooltip are different elements that don’t need to be parent/child. They can be anywhere in the DOM and the CSS can handle that (though, for practical and accessibility reasons, you may want to keep them close together and associate them).

The HTML structure you use will depend on your use case and your type of content, so choose it carefully. In all cases, it’s mainly one element for the anchor and another one for the tooltip.

Here is a demo taken from another article where the anchor is the thumb of a slider and the tooltip is an <output> element:

The CSS:

#anchor {
  anchor-name: --anchor;
}
#tooltip {
  position: absolute; 
  position-anchor: --anchor;
  position-area: top;
}

We define the anchor using anchor-name, link the tooltip to the anchor using position-anchor (and a custom ident, the --anchor bit that looks like a custom property but is really just a unique name), and then we place it at the top using position-area. The tooltip needs to be absolutely positioned (which includes fixed position as well).

Nothing fancy so far. The tooltip is “always” placed at the top, whatever the anchor’s position. You can drag the anchor to see the result.

In this article we’ll use simple values for position-area, but this property can be very tricky.

A grid layout demo showing various cell configurations and an anchor icon with the text 'CSS Is Awesome' positioned at the top left.
I’ve created an interactive demo if you want to explore all the different values and understand how alignment works in the context of Anchor Positioning.

Now that our tooltip is placed, let’s add a small offset at the bottom to prepare the space for the tail. Using bottom will do the job.

#anchor {
  anchor-name: --anchor;
}
#tooltip {
  --d: 1em; /* distance between tooltip and anchor */

  position: absolute; 
  position-anchor: --anchor;
  position-area: top;
  bottom: var(--d);
}

Making the Position Dynamic

Let’s move to the interesting part, where we will adjust the position of the tooltip to make it always visible and avoid any overflow. Anchor Positioning has native mechanisms to do this, and all we need to do is understand how to use them correctly.

The first thing is to identify the containing block of the absolutely positioned element. We may intuitively think that the logic is to avoid a screen overflow, but that’s not the case. It’s related to the containing block. This can be very confusing if you don’t understand this part, so let’s look closely.

The specification:

Anchor positioning, while powerful, can also be unpredictable. The anchor box might be anywhere on the page, so positioning a box in any particular fashion might result in the positioned box overflowing its containing block or being positioned partially off screen.

To ameliorate this, an absolutely positioned box can use the position-try-fallbacks property to refer to several variant sets of positioning/alignment properties that the UA can try if the box overflows its initial position. Each is applied to the box, one by one, and the first that doesn’t cause the box to overflow its containing block is taken as the winner.

As you can read, it’s all about the containing block, and the containing block of an absolutely positioned element is the first ancestor with a position different from static (the default). If such element doesn’t exist we consider the initial containing block.

In our example, I am going to use the body as the containing block, and I will add a border and an offset from each side to better illustrate:

Drag the anchor to the left or the right and see what happens. When the tooltip touches the edges, it stops, even if you can still move the anchor. It overflows the body only when the anchor is getting outside.

The browser will initially place the tooltip at the top and centered. The priority is to remain withing the containing block, so if there isn’t enough space to keep the center behavior, the tooltip is shifted. The second priority is to keep the anchor behavior, and in this case, the browser will allow the overflow if the anchor element is outside.

A three-part interactive demo showing a tooltip following an anchor element as it is dragged. The tooltip displays the message 'Drag the anchor and I should follow...' with an anchor icon below.

Assuming the anchor will remain within the body area, we already have what we want without too much effort. The tooltip will never overflow from the right, left, or bottom side. What remains is the top.

By default, the browser can shift the element within the area defined by position-area, but cannot do more than that. We need to instruct the browser on how to handle the other cases. For this, we use position-try-fallbacks where we define different positions for the browser to “try” in case the element doesn’t fit its containing block.

Let’s define a bottom position:

position-try-fallbacks: bottom;

Drag the anchor to the top and see what happens:

Now, when the tooltip overflows the body from the top, the position becomes “bottom”. It will also remain bottom until the tooltip overflows again from the bottom. In other words, when the browser picks a position after an overflow, it keeps it until a new overflow happens.

That’s all, we are done! Now our tooltip is perfectly placed, whatever the anchor position.

But we no longer have the gap when the position is at the bottom (for the future arrow). How do we fix that?

We told the browser to only change the value of position-area to bottom, but we can do better by using:

position-try-fallbacks: flip-block;

“Block” refers to the block axis (the vertical axis in our default writing mode), and this instruction means flip the position across the vertical axis. The logic is to mirror the initial position on the other side. To do this the browser needs to update different properties in addition to position-area.

In the example we’ve defined position-area: top and bottom: var(--d). With position-try-fallbacks: flip-block in place, when that flip happens, it’s as if we defined position-area: bottom and top: var(--d). We keep the gap!

If you are a bit lost and confused, don’t worry. We are dealing with new mechanisms not common to the CSS world so it may take time to click for you.

To sum up, we can either instruct the browser to update only the position-area by defining a new position or to “flip” the actual position across one axis which will update different properties.

Adding The Tail

Adding a tail to a tooltip is pretty straightforward (I even have a collection of 100 different designs), but changing the direction of the tail based on the position is a bit tricky.

Three tooltip examples illustrating text that says 'Drag the anchor and I should follow...' with an anchor icon, showcasing dynamic positioning.

For now, Anchor Positioning doesn’t offer a way to update the CSS based on the position, but we can still use the existing features to “hack” it. Hacking with CSS can be fun!

I am going to rely on the “flip” feature and the fact that it can update the margin to achieve the final result.

First, I will consider a pseudo-element to create the tail shape:

#tooltip {
  --d: 1em; /* distance between tooltip and anchor */
  --s: 1.2em; /* tail size */
}
#tooltip::before {
  content: "";
  position: absolute;
  z-index: -1;
  width: var(--s);
  background: inherit;
  inset: calc(-1*var(--d)) 0;
  left: calc(50% - var(--s)/2);
  clip-path: polygon(50% .2em,100% var(--d),100% calc(100% - var(--d)),50% calc(100% - .2em),0 calc(100% - var(--d)),0 var(--d));
}

Both tails are visible by default. Click “debug mode” to better understand the shape and how it’s placed.

When the tooltip is at the top, we need to hide the top part. For this, we can use a margin-top on the pseudo-element equal to variable --d. And when the tooltip is at the bottom, we need margin-bottom.

I am going to define the margin on the tooltip element and then inherit it on pseudo-element:

#tooltip {
  --d: 1em; /* distance between tooltip and anchor */
  --s: 1.2em; /* tail size */

  margin-top: var(--d);
}
#tooltip::before {
  margin: inherit;
}

Tada. Our tooltip is now perfect! The use of margin will hide one side keeping one tail visible at a time.

But we didn’t define margin-bottom. How does it work for the bottom position?

That’s the “flip” feature. Remember what we did with the gap where we only defined top and flip-block changed it to bottom? The same logic applies here with margin: the margin-top automatically becomes a margin-bottom when the position is flipped! Cool, right?

Note that using margin will cause the tooltip to flip a bit earlier since margin is part of the element, and the logic is to prevent the overflow of the “margin box”. It’s not a big deal in our example; it’s nicer to flip the position before it touches the edges.

Moving The Tail

The top and bottom parts are good, but we still need to fix the cases where the tooltip shifts when it’s close to the left and right edges. The tail needs to follow the anchor. For this, we have to update the left value and make it follow the anchor position.

Instead of:

left: calc(50% - var(--s));

We use:

left: calc(anchor(--anchor center) - var(--s)/2); 

I replace 50%, which refers to the center of the tooltip element, with anchor(--anchor center), which is the center of the anchor element.

The anchor() function is another cool feature of Anchor Positioning. It allows us to query a position from any anchor element and use it to place an absolutely positioned element.

Uh oh — that doesn’t work. I’ve left this in though, as it’s a educational moment we need to look at.

We hit one of the trickiest issues of Anchor Positioning. In theory, any element on the page can be an anchor using anchor-name and any other element can position itself relative to that anchor. That’s the main purpose of the feature but there are exceptions where an element cannot reference an anchor.

I won’t detail all the cases, but in our example, the pseudo-element (the tail) is a child of the tooltip, which is an absolutely positioned element. This makes the tooltip the containing block of the pseudo-element and prevents it from seeing any anchor defined outside it. (If you think z-index and stacking context are hard, get ready for this)

To overcome this, I will update the position of the pseudo-element to fixed. This changes its containing block (the viewport at the moment) and makes it able to see the anchor element.

Yes, the demo is broken, but drag the anchor close to the edges and see how the tail is correctly placed horizontally as it’s now able to “see” the anchor element. However, the pseudo-element now has a fixed position so it can no longer be placed relatively to its parent element, the tooltip. To fix this we can make the tooltip an anchor element as well, so the pseudo-element can reference it.

In the end we need two anchors: #anchor and #tooltip. The tooltip is positioned relatively to the anchor, and the tail is positioned relatively to both the anchor and the tooltip.

#anchor {
  position: absolute;
  anchor-name: --anchor;
}
#tooltip {
  --d: 1em;  /* distance between anchor and tooltip  */
  --s: 1.2em; /* tail size */
  
  position: absolute; 
  position-anchor: --anchor;
  anchor-name: --tooltip;
}
/* the tail */
#tooltip:before {
  content: "";
  position: fixed;
  z-index: -1;
  width: var(--s);
  /* vertical position from tooltip  */
  top:    calc(anchor(--tooltip top   ) - var(--d));
  bottom: calc(anchor(--tooltip bottom) - var(--d));
  /* horizontal position from anchor */
  left: calc(anchor(--anchor center) - var(--s)/2);
}

Thanks to anchor(), I can retrieve the top and bottom edges of the tooltip element and the center of the anchor element to correctly position the pseudo-element.

Our tooltip is now perfect! As I mentioned in the introduction, this CSS is not particularly complex. We barely used 20 declarations.

What if we want to start with the bottom position?

Easy! You simply change the initial configuration to consider the bottom position and flip-block will switch to the top one when there is an overflow.

#tooltip {
  position-area: bottom; /* instead of position-area: top; */
  top: var(--d); /* instead of bottom: var(--d); */
  margin-bottom: var(--d); /* margin-top: var(--d) */
}

Conclusion

That’s all for this first part. We learned how to place a tooltip using position-area and how to defined a fallback position when an overflow occurs. Not to mention the flip feature and the use of the anchor() function.

In the second part, we will increase the difficulty by working with more than two positions. Take the time to digest this first part before moving to the next one I also invite you to spend a few minutes on my interactive demo of position-area to familiarize yourself with it.

Article Series

]]>
https://frontendmasters.com/blog/perfectly-pointed-tooltips-a-foundation/feed/ 3 7514
Infinite Marquee Animation using Modern CSS https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/ https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/#comments Mon, 04 Aug 2025 18:30:28 +0000 https://frontendmasters.com/blog/?p=6673 A set of logos with an infinite repeating slide animation is a classic component in web development. We can find countless examples and implementations starting from the old (and now deprecated) <marquee> element. I’ve written an article about it myself a few years ago.

“Why another article?” you ask. CSS keeps evolving with new and powerful features, so I always try to find room for improvement and optimization. We’ll do that now with some new CSS features.

At the time of writing, only Chrome-based browsers have the full support of the features we will be using, which include features like shape(), sibling-index(), and sibling-count().

In the demo above, we have an infinite marquee animation that works with any number of images. Simply add as many elements as you want in the HTML. There is no need to touch the CSS. You can easily control the number of visible images by adjusting one variable, and it’s responsive. Resize the screen and see how things adjust smoothly.

You might think the code is lengthy and full of complex calculations, but it’s less than 10 lines of CSS with no JavaScript.

.container {
  --s: 150px; /* size of the images */
  --d: 8s; /* animation duration */
  --n: 4; /* number of visible images */
  
  display: flex;
  overflow: hidden;
}
img {
  width: var(--s);
  offset: shape(from calc(var(--s)/-2) 50%,hline by calc(sibling-count()*max(100%/var(--n),var(--s))));
  animation: x var(--d) linear infinite calc(-1*sibling-index()*var(--d)/sibling-count());
}
@keyframes x { 
  to { offset-distance: 100%; }
}

Perhaps this looks complex at first glance, especially that strange offset property! Don’t stare too much at it; we will dissect it together, and by the end of the article, it will look quite easy.

The Idea

The tricky part when creating a marquee is to have that cyclic animation where each element needs to “jump” to the beginning to slide again. Earlier implementations will duplicate the elements to simulate the infinite animation, but that’s not a good approach as it requires you to manipulate the HTML, and you may have accessibility/performance issues.

Some modern implementations rely on a complex translate animation to create the “jump” of the element outside the visible area (the user doesn’t see it) while having a continuous movement inside the visible area. This approach is perfect but requires some complex calculation and may depend on the number of elements you have in your HTML.

It would be perfect if we could have a native way to create a continuous animation with the “jump” and, at the same time, make it work with any number of elements. The first part is doable and we don’t need modern CSS for it. We can use offset combined with path() where the path will be a straight line.

Inside path, I am using the SVG syntax to define a line, and I simply move the image along that line by animating offset-distance between 0% and 100%. This looks perfect at first glance since we have the animation we want but it’s not a flexible approach because path() accepts only hard-coded pixel values.

To overcome the limitation of path(), we are going to use the new shape() function! Here is a quote from the specification:

The shape() function uses a set of commands roughly equivalent to the ones used by path(), but does so with more standard CSS syntax, and allows the full range of CSS functionality, such as additional units and math functions … In that sense, shape() is a superset of path().

Instead of drawing a line using path(), we are going to use shape() to have the ability to rely on CSS and control the line based on the number of elements.

Here is the previous demo using shape():

If you are unfamiliar with shape(), don’t worry. Our use case is pretty basic as we are going to simply draw a horizontal line using the following syntax:

offset: shape(from X Y, hline by length);

The goal is to find the X Y values (the coordinates of the starting point) and the length value (the length of the line).

The Implementation

Let’s start with the HTML structure, which is a set of images inside a container:

<div class="container">
  <img src="">
  <img src="">
  <!-- as many images as you want -->
</div>

We make the container flexbox to remove the default space between the image and make sure they don’t wrap even if the container is smaller (remember that flex-wrap is by default nowrap).

Now, let’s suppose we want to see only N images at a time. For this, we need to define the width of the container to be equal to N x size_of_image.

.container {
  --s: 100px; /* size of the image */
  --n: 4; /* number of visible images */

  display: flex;
  width: calc(var(--n) * var(--s));
  overflow: hidden;
}
img {
  width: var(--s);
}

Nothing complex so far. We introduced some variables to control the size and the number of visible images. Now let’s move to the animation.

To have a continuous animation, the length of the line needs to be equal to the total number of images multiplied by the size of one image. In other words, we should have a line that can contain all the images side by side. The offset property is defined on the image elements, and thanks to modern CSS, we can rely on the new sibling-count() to get the total number of images.

offset: shape(from X Y, hline by calc(sibling-count() * var(--s)));

What about the X Y values? Let’s try 0 0 and see what happens:

Hmm, not quite good. All the images are above each other, and their position is a bit off. The first issue is logical since they share the same animation. We will fix it later by introducing a delay.

The trickiest part when working with offset is defining the position. The property is applied on the child elements (the images in our case), but the reference is the parent container. By specifying 0 0, we are considering the top-left corner of the parent as the starting point of the line.

What about the images? How are they placed? If you remove the animation and keep the offset-distance equal to 0% (the default value), you will see the following.

An animated marquee with text that moves horizontally across a container, showcasing a modern CSS implementation for infinite scrolling images or text.

The center of the images is placed at the 0 0, and starting from there, they move horizontally until the end of the line. Let’s update the X Y values to rectify the position of the line and bring the images inside the container. For this, the line needs to be in the middle 0 50%.

offset: shape(from 0 50%, hline by calc(sibling-count() * var(--s)));

It’s better, and we can already see the continuous animation. It’s still not perfect because we can see the “jump” of the image on the left. We need to update the position of the line so it starts outside the container and we don’t see the “jump” of the images. The X value should be equal to -S/2 instead of 0.

offset: shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * var(--s)));

No more visible jump, the animation is perfect!

To fix the overlap between the images, we need to consider a different delay for each image. We can use nth-child() to select each image individually and define the delay following the logic below:

img:nth-child(1) {animation-delay: -1 *  duration/total_image }
img:nth-child(2) {animation-delay: -2 *  duration/total_image }
/* and so on */

Tedious work, right? And we need as many selectors as the number of images in the HTML code, which is not good. What we want is a generic CSS code that doesn’t depend on the HTML structure (the number of images).

Similar to the sibling-count()that gives us the total number of images, we also have sibling-index() that gives us the index of each image within the container. All we have to do is to update the animation property and include the delay using the index value that will be different for each image, hence a different delay for each image!

animation: 
  x var(--d) linear infinite 
  calc(-1*sibling-index()*var(--d)/sibling-count());

Everything is perfect! The final code is as follows:

.container {
  --s: 100px; /* size of the image */
  --d: 4s; /* animation duration */
  --n: 4; /* number of visible images */
  
  display: flex;
  width: calc(var(--n) * var(--s));
  overflow: hidden;
}
img {
  width: var(--s);
  offset: shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * var(--s)));
  animation: x var(--d) linear infinite calc(-1*sibling-index()*var(--d)/sibling-count());
}
@keyframes x { 
  to {offset-distance: 100%}
}

We barely have 10 lines of CSS with no hardcoded values or magic numbers!

Let’s Make it Responsive

In the previous example, we fixed the width of the container to accommodate the number of images we want to show but what about a responsive behavior where the container width is unknown? We want to show only N images at a time within a container that doesn’t have a fixed width.

The observation we can make is that if the container width is bigger than NxS, we will have space between images, which means that the line defined by shape() needs to be longer as it should contain the extra space. The goal is to find the new length of the line.

Having N images visible at a time means that we can express the width of the container as follows:

width = N x (image_size + space_around_image)

We know the size of the image and N (Defined by --s and --n), so the space will depend on the container width. The bigger the container is, the more space we have. That space needs to be included in the length of the line.

Instead of:

hline by calc(sibling-count() * var(--s))

We need to use:

hline by calc(sibling-count() * (var(--s) + space_around_image))

We use the formula of the container width and replace (var(--s) + space_around_image) with width / var(--n) and get the following:

hline by calc(sibling-count() * width / var(--n) )

Hmm, what about the width value? It’s unknown, so how do we find it?

The width is nothing but 100%! Remember that offset considers the parent container as the reference for its calculation so 100% is relative to the parent dimension. We are drawing a horizontal line thus 100% will resolve to the container width.

The new offset value will be equal to:

shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * 100% / var(--n)));

And our animation is now responsive.

Resize the container (or the screen) in the below demo and see the magic in play:

We have the responsive part but it’s still not perfect because if the container is too small, the images will overlap each other.

We can fix this by combining the new code with the previous one. The idea is to make sure the length of the line is at least equal to the total number of images multiplied by the size of one image. Remember, it’s the length that allows all the images to be contained within the line without overlap.

So we update the following part:

calc(sibling-count() * 100%/var(--n))

With:

max(sibling-count() * 100%/var(--n), sibling-count() * var(--s))

The first argument of max() is the responsive length, and the second one is the fixed length. If the first value is smaller than the second, we will use the latter and the images will not overlap.

We can still optimize the code a little as follows:

calc(sibling-count() * max(100%/var(--n),var(--s)))

We can also add a small amount to the fixed length that will play the role of the minimum gap between images and prevent them from touching each other:

calc(sibling-count() * max(100%/var(--n),var(--s) + 10px))

We are done! A fully responsive marquee animation using modern CSS.

Here is again the demo I shared at the beginning of the article with all the adjustments made:

Do you still see the code as a complex one? I hope not!

The use of min() or max() is not always intuitive, but I have a small tutorial that can help you identify which one to use.

More Examples

I used images to explain the technique, but we can easily extend it to any kind of content. The only requirement/limitation is to have equal-width items.

We can have some text animations:

Or more complex elements with image + text:

In both examples, I am using flex-shrink: 0 to avoid the default shrinking effect of the flex items when the container gets smaller. We didn’t have this issue with images as they won’t shrink past their defined size.

Conclusion

Some of you will probably never need a marquee animation, but it was a good opportunity to explore modern features that can be useful such as the shape() and the sibling-*() functions. Not to mention the use of CSS variables, calc(), max(), etc., which I still consider part of modern CSS even if they are more common.

]]>
https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/feed/ 6 6673
Drawing CSS Shapes using corner-shape https://frontendmasters.com/blog/drawing-css-shapes-using-corner-shape/ https://frontendmasters.com/blog/drawing-css-shapes-using-corner-shape/#comments Wed, 18 Jun 2025 17:05:26 +0000 https://frontendmasters.com/blog/?p=6235 We recently got the new shape() function for clip-path which is a game changer for creating CSS shape. Another cool feature is on the way and will soon be available: corner-shape.

I will not call it “new” because it’s something that has been around for quite a while and you can find countless GitHub discussions around it. There is even an 11-year old Github Repo made by Lea Verou with an interactive demo showing a few examples! After all that time, It finally has its own specification and is ready to be implemented and shipped.

At the time of writing, corner-shape is available in Chrome v139 or 136+ with the experimental web features flag turned on, but no other browsers yet.

What is corner-shape?

When you define a border-radius you will get rounded corners. corner-shape allows you to change those rounded corners to something else. It’s in the name; it changes the “shape” of the “corner”.

A graphic displaying different CSS corner shapes: 'round', 'scoop', 'bevel', 'notch', and 'squircle', each in a purple background with white text.

The value round is the default (the classic rounded corners). As you can see above, we have many cool variations. All of this with a simple syntax:

.corner {
  border-radius: 30px;
  corner-shape: round | scoop | bevel | notch | squircle;
}

We can also use the superellipse(K) value that can define all the different variations and more by adjusting the K variable. I will not detail that part as it’s not important for the article but it’s good to know so I invite you to check the (draft) specification for more detail.

Diagram illustrating various 'superellipse()' values for corner shapes with labeled corners and arrows indicating direction. Shows how the corner shape changes with different K values.

Let’s Draw Shapes

Changing the corner shape is good — but how can we draw shapes? The answer is to play with border-radius. Follow along to see all the magic we can do with corner-shape!

Rhombus & Octagon

If you look closely at the example using the bevel value, you will see that we have 8 sides since the corners are diagonal straight lines so we almost have an octagon shape. We simply need to find the exact value for border-radius that gives us 8 equal sides.

I will skip the boring math and give you the final value which is:

border-radius: calc(100%/(2 + sqrt(2)));

Even without being precise, you can approximate the value using trial & error. You will get an octagon when you are close to 29%. The usage of percentage is important because it means the shape is responsive and let’s not forget aspect-ratio: 1 as well.

.octagon {
  border-radius: calc(100%/(2 + sqrt(2)));
  corner-shape: bevel;
  aspect-ratio: 1;
} 

Now what if we keep increasing the radius? We get a Rhombus shape at 50%.

.rhombus {
  border-radius: 50%;
  corner-shape: bevel;
  aspect-ratio: 1;
} 

Some of you might ask if this method is better than what we already have. In my shape collection, you can easily find the code of the above shapes made using clip-path so why another method?

First of all, the syntax is a bit easier than the clip-path one so this can improve the readability of the code as we have fewer values to deal with. But the most important advantage is that we can add a border to the shape! Adding borders to custom shapes is always a nightmare but corner-shape made it easy.

This is logical since, by default, when we add border-radius, the border and other decorations such as box-shadow will follow the rounded corners. It’s still the case even if we change the shape of the corner.

Five shapes with different corner styles labeled: round, scoop, bevel, notch, and squircle, all displayed on a purple background.

Here are the rhombus and octagon shapes with borders:

Note how we can have a border-only version if we keep the background transparent and we can also apply the shape to an image as well. Cool, right?

Hexagon

Do you see how can we achieve a hexagon shape? Try to think about it before reading my explanation.

The trick is to rely on the fact that border-radius accepts vertical and horizontal values, something we always forget about. Let’s take the rhombus shape and decrease the vertical or the horizontal radius.

Do you see that? We have an “almost” hexagon shape. All that is missing is the correct aspect-ratio.

.hexagon {
  border-radius: 50% / 25%; /* OR 25% / 50% */
  corner-shape: bevel;
  aspect-ratio: cos(30deg); /* OR 1/cos(30deg) */
}

We can definitely say that we have the easiest and simplest way to create hexagon shapes!

Triangles

Following the same logic we can create most of the common shapes and triangles aren’t an exception. Again, we can use the Rhombus as a starting point and adjust either the horizontal or the vertical radius like below.

This one can be a bit tricky at first glance because we don’t have the 4 diagonal lines for each corner like the previous shapes but don’t forget that we can use 0 with border-radius which will disable the corresponding corner.

.triangle {
  border-radius: 50% / 100% 100% 0 0; 
  corner-shape: bevel;
} 

From there, we can easily get any kind of triangle by trying the different radius combinations and also playing with aspect-ratio.

Below is a demo with many examples. Try to create some of them before checking my code. It’s the perfect exercise to practice with corner-shape.

The only caveat with triangle shapes is that the border is not perfect. It may sound like a bug but it’s not. I won’t detail the logic behind this but if you want to add a border, you may need a different thickness for one or many sides.

Here is an example with one of the triangle shapes where I am increasing the thickness of the top border a little to have a perfect-looking shape.

As you can see in the code, there is a math formula to get the correct thickness but since it will be a different formula for each triangle shape, I won’t bother you with a boring explanation. Plus you can easily (and rapidly) get a good result with some trial & error. No need to be very precise.

Slanted edge

All the shapes we created rely on percentage values but border-radius accepts length as well, which means we can have elements with variable size but the shape remains static.

Example with a slanted edge where the slant keeps the same size whatever the element width:

The code is a simple as:

.slanted {
  border-top-right-radius: 80px 100%;
  corner-shape: bevel;
}

No need for the shorthand property in this case since only the top-right corner matters. As for the value, I think it’s self-explanatory. Simply notice that there is no / to separate the horizontal and vertical radius when using the longhand properties.

Arrow-like box

Using the same logic we can have an arrow-like box:

.arrow {
  border-radius: 80px / 0 50% 50% 0;
  corner-shape: bevel;
}

Trapezoid & Parallelogram

Also trapezoid & parallelogram shapes:

.trapezoid {
  border-radius: 80px / 100% 0 100% 0;
  corner-shape: bevel;
}
.parallelogram {
  border-radius: 80px / 100% 100% 0 0;
  corner-shape: bevel;
}

Conclusion

Using only the bevel option of corner-shape we were able to create a lot of different shapes easily. All we had to do was to play with border-radius, a well-known property. Not to mention the fact that we can easily add borders and box shadows which is a real game changer compared to shapes created using clip-path or mask.

I will end the article with a last demo where I combine bevel and notch to create an arrow. Yes, you can have a different shape per corner!

What about you? Can you think about a cool shape using corner-shape?

]]>
https://frontendmasters.com/blog/drawing-css-shapes-using-corner-shape/feed/ 8 6235
Creating Blob Shapes using clip-path: shape() https://frontendmasters.com/blog/creating-blob-shapes-using-clip-path-shape/ https://frontendmasters.com/blog/creating-blob-shapes-using-clip-path-shape/#respond Mon, 19 May 2025 14:30:05 +0000 https://frontendmasters.com/blog/?p=5861 After the flower shapes, let’s move to one of the coolest shapes: the Blob! Those distorted wiggly circles that were almost impossible to achieve using CSS. But now, they are possible using the new shape() function.

Article Series

Before we start, take a look at my blob shape generator. Unlike the flower shapes, blobs have the random factor so having a generator to get the code is a lifesaver. This said, stay with me to understand the logic behind creating them, maybe you will want to make your own generator of blobs.

For this shape, we are going to rely on the curve command, so let’s start by understanding how it works.

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

The curve Command

This command allows you to draw Bézier curves between two points. With the arc command, we needed a radius, but here we need control points. We can either have one control point and create a Quadratic curve or two control points and create a Cubic curve.

Here is a figure to illustrate a few examples. The black dots illustrate the control points, and the blue ones the starting and ending points.

Illustration showing the difference between creating curves with one control point and two control points using Bézier curves. The top left and bottom left demonstrate the one control point method, while the top right and bottom right illustrate the two control points method.

And a demo:

I won’t detail the exact geometry behind the curves, but notice their behavior close to the starting and ending points. The curve is tangent to the lines that link the starting and ending points with the control points. This will be the key to create our blob shape.

The code of this command is:

clip-path: shape(from Xa Ya, curve to Xb Yb with Xc1 Yc1 / Xc2 Yc2) 

And I will be using one control point, so we can omit the second control point:

clip-path: shape(from Xa Ya, curve to Xb Yb with Xc1 Yc1) 

By combining many curves, we can create a blob. We have to understand how to correctly combine them, so it’s time for a small geometry course.

The Geometry of The Blob

Mathematically speaking, there is no specific geometry for a blob because it’s not a shape we can formally define. We can implement a blob using different methods and calculations, so what I am going to share is my own implementation. It’s probably not the best one, but it gives a good result.

We first start by placing N points around a circle. The number of points is the first parameter of the blob that I am calling “granularity” in my generator. Then I define a distance D I call the depth.

Now, we randomly move the points within the area defined by the distance D. For each point, we pick a random value in the range [0 D] and we make it closer to the center using that value.

For the next step, we take two consecutive points, draw a line between them, and then place a new point at the center. This will double the number of points.

The last step is to draw the Bézier curves. The new points (the blue ones) are the starting and ending points, and the initial points (the black ones) are the control points.

The fact that two adjacent curves share the same tangent is what gives us a continuous and smooth shape, a perfect blob!

That’s it. Now let’s translate this into code.

The Code of The Blob

Similar to the flower shape, the code will be a bunch of curve commands like below:

.blob { 
  clip-path: shape(from X0 Y0, 
     curve to X1 Y1 with Xc1 Yc1,
     curve to X2 Y2 with Xc2 Yc2,
     curve to X3 Y3 with Xc3 Yc3,
     curve to X4 Y4 with Xc4 Yc4,
     ...
     curve to Xn Yn with Ycn Ycn
  )
}

[Xi, Yi] are the starting/ending points (the blue ones), and [Xci, Yci] are the control points (the black ones). For the sake of simplicity, I will use pseudo-code to illustrate the calculation. The real implementation can be done using JavaScript like in my generator, or using Sass (I will share a demo using Sass later).

We first start by defining the control points:

N = 15  /* number of points (granularity) */
D = 20% /* depth */

for i in [1 N] {
R = 50% - random(D);
Xci = 50% + R*math.cos(360deg * i/N));
Yci = 50% + R*math.sin(360deg * i/N));
}

R will define the distance of the points from the center, and it will have a random value between 50% and 50% - D.

Then we define the main points where each one is placed at the center of two consecutive control points:

for i in [1 N] {
Xi = (Xci + Xci+1)/2;
Yi = (Yci + Yci+1)/2;
}

Finally, the shape function will be as follows:

clip-path: shape(from X0 Y0, 
for i in [1 N] {
curve to Xi Yi with Xci Yci,
}
)

Here is a Sass implementation:

One observation we can make is that the shape is responsive. It’s designed to work with square elements (aspect-ratio: 1), but the result is not bad for rectangular elements as well. Resize the element in the demo below and see how the shape behaves:

The code can also be tweaked to create more variations. We can, for example, have a kind of wavy circle by removing the random part and applying a fixed distance to half the points.

Can you think of other variations?

Animating The Blob

Having the blob shape in CSS is already a cool feature. It’s one line of code that can be applied to any element, including images, and it’s responsive! In addition to this, we can easily animate them. The only requirement is to have the same structure inside shape(). So if we take two blobs having the same number of curve commands, then we can animate one into another!

Here is an example where we keep the same number of points and only adjust the depth:

You copy both codes from the generator, apply a transition, and you have a cool hover effect that transforms a circle into a blob!

The bouncing effect you get is made with the linear() function which is another cool feature for custom easing. I am getting the code from here.

Now, if you update the Shape ID and still keep the same number of points, you can have a transition between two different blobs.

Cool, right? The code may look complex but in the end everything is generated for you, so it’s nothing but a few clicks to get a fancy shape with a nice animation! Speaking about animation, let’s end with a demo using a keyframes instead of a transition.

Conclusion

I hope you enjoyed this shape() exploration through this series of articles. Once this feature becomes widely supported, it will be a game changer and we can forget about all the hacky workarounds to create CSS shapes.

Don’t forget to keep an eye on my CSS Shapes and CSS Generators websites from where you can easily copy the code of any CSS shape.

Article Series

]]>
https://frontendmasters.com/blog/creating-blob-shapes-using-clip-path-shape/feed/ 0 5861
Creating Flower Shapes using clip-path: shape() https://frontendmasters.com/blog/creating-flower-shapes-using-clip-path-shape/ https://frontendmasters.com/blog/creating-flower-shapes-using-clip-path-shape/#respond Mon, 12 May 2025 14:51:36 +0000 https://frontendmasters.com/blog/?p=5824 In a previous article, we used modern CSS features such as mask, trigonometric functions, and CSS variables to create flower-like shapes.

The HTML code was a single element, which means we can apply the CSS to image elements and get cool frames like the demo below:

In this article, we are redoing the same shapes using the new shape() function, which I think will become my favorite CSS feature.

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

Article Series

What is shape()?

You are probably familiar with clip-path: polygon(), right? A function that allows you to specify different points, draw straight lines between them and create various CSS shapes (I invite you to check my online collection of CSS shapes to see some of them). I said “straight lines” because when it comes to curves, clip-path is very limited. We have circle() and ellipse(), but we cannot achieve complex shapes with them.

shape() is the new value that overcomes such limitation. In addition to straight lines, it allows us to draw curves. But If you check the MDN page or the specification, you can see that the syntax is a bit complex and not easy to grasp. It’s very similar to SVG path, which is good as it gives us a lot of options and flexibility, but it requires a lot of practice to get used to it.

I will not write a boring tutorial explaining the syntax and all the possible values, but rather focus on one command per article. In this article, we will study the arc command, and the next article will be about the curve command. And, of course, we are going to draw cool shapes. Otherwise it’s no fun!

The arc Command

This command allows you to draw an elliptic arc between two points but I will only consider the particular case of a circle which is easier and the one you will need the most. Let’s start with some geometry to understand how it works.

If you have two points (A and B) and a radius, there are exactly two circles with the given radius that intersect with the points. The intersection of both circles gives us 4 possible arcs between A and B that we can identify with a size (small or large) and a direction (clockwise or counter-clockwise)

The code will look like the below:

clip-path: shape(from Xa Ya, arc to Xb Yb of R [large or small] [cw or ccw])

The first command of a shape is always a from to give the starting point, and then we use the arc to define the ending point, the radius, the size, and the direction.

Here is a demo to illustrate the different values:

The points and the radii are the same. I am only changing the size and direction to choose one of the four possibilities. It should be noted that the size and direction aren’t mandatory. The defaults are small and ccw.

That’s all: we have what we need to draw flower shapes!

Creating The Flower Shape

I will start with a figure from the previous article.

Using the mask method, we had to draw a big circle at the center and small circles placed around it. Using shape(), we need to draw the arcs of the small circles. The starting and ending points of each arc are placed where the circles touch each other.

The code will look as follows:

.flower { 
  clip-path: shape(from X0 Y0, 
     arc to X1 Y1 of R,
     arc to X2 Y2 of R,
     arc to X3 Y3 of R,
     arc to X4 Y4 of R,
     ...
     arc to Xn Yn of R
  )
}

And like I did with the mask method, I will rely on Sass to create a loop.

$n: 10;

.flower {
  $m: ();
  $m: append($m,from X0 Y0,comma);
  @for $i from 1 through $n {
    $m: append($m,arc to Xi Yi of R,comma);
  } 
  clip-path: shape(#{$m});
}

Now we need to identify the radius of the small circles (R) and the position of the different points (Xi, Yi). I already did the calculation of the radius in the previous article, so I will reuse the same value:

R = 50%/(1 + 1/math.sin(180deg/$n))

For the points, they are placed around the same circle so their coordinate can be written like below:

Xi = 50% + D*math.cos($i*360deg/$n)
Yi = 50% + D*math.sin($i*360deg/$n)

Here is a figure to illustrate the distance D and the radius R:

I know you don’t want a boring geometry course so here is the value of D.

D = 50%/(math.tan(180deg/$n) + 1/math.cos(180deg/$n))

And the final code will be:

$n: 10;

.flower {
  $m: ();
  $r: 50%/(1 + 1/math.sin(180deg/$n));
  $d: 50%/(math.tan(180deg/$n) + 1/math.cos(180deg/$n));
  $m: append($m,from 
    50% + $d*math.cos(0) 
    50% + $d*math.sin(0),comma);
  @for $i from 1 through $n {
    $m: append($m,arc to 
      50% + $d*math.cos($i*360deg/$n)
      50% + $d*math.sin($i*360deg/$n)
      of $r,comma);
  } 
  clip-path: shape(#{$m});  
}

Wait, we get the inverted shape instead? Why is that?

We didn’t define the size and the direction of the arcs so by default the browser will use small and ccw. This gives us the inverted version of the flower. If you try the 4 different combinations (including the default one) you will get the following:

small ccw and large cw give us the shape we are looking for. The small cw is an interesting variation, and the large ccw is a funny one. We can consider a CSS variable to easily control which shape we want to get.

Combining Both Shapes

Now, what about the shape below?

The idea is to use the same code and alternate between two arc configurations.

$n: 10;

.flower {
  $m: ();
  $r: 50%/(1 + 1/math.sin(180deg/$n));
  $d: 50%/(math.tan(180deg/$n) + 1/math.cos(180deg/$n));
  $m: append($m,from 
    50% + $d*math.cos(0) 
    50% + $d*math.sin(0),comma);
  @for $i from 1 through $n {
    $c:small ccw;
    @if $i % 2 == 0 {$c:large cw}
    $m: append($m,arc to 
      50% + $d*math.cos($i*360deg/$n)
      50% + $d*math.sin($i*360deg/$n)
      of $r $c,comma);
  } 
  clip-path: shape(#{$m});  
}

I introduced a new variable $c within the loop that will have the value small ccw when $i is odd and large cw otherwise.

Cool right? The compiled code will look like the below:

clip-path: 
shape(from X0 Y0,
arc to X1 Y1 of R small ccw,
arc to X2 Y2 of R large cw,
arc to X3 Y3 of R small ccw,
arc to X4 Y4 of R large cw,
...
)

The first arc will use the inner curve (small ccw), the next one the outer curve (large cw) and so on. We repeat this to get our wavy-flower shape!

Optimizing The Code

We made a generic code that allow us to get any shape variation by simply controlling the size/direction of the arcs, but for each particular case, we can have a more simplified code.

For the inverted variation (small ccw), the value of D can be replaced by 50%. This will simplify the formula and also increase the area covered by the shape. We also need to update the value of the radius.

$n: 10;

.flower {
  $m: ();
  $r: 50%*math.tan(180deg/$n);
  $m: append($m,from 
    50% + 50%*math.cos(0) 
    50% + 50%*math.sin(0),comma);
  @for $i from 1 through $n {
    $m: append($m,arc to 
      50% + 50%*math.cos($i*360deg/$n)
      50% + 50%*math.sin($i*360deg/$n)
      of $r,comma);
  } 
  clip-path: shape(#{$m});  
}

We can do the same for the main shape, but this time we can simplify the value of the radius and use 1%.

$n: 10;

.flower {
  $m: ();
  $d: 50%/(math.cos(180deg/$n)*(1 + math.tan(180deg/$n)));
  $m: append($m,from 
    50% + $d*math.cos(0) 
    50% + $d*math.sin(0),comma);
  @for $i from 1 through $n {
    $m: append($m,arc to 
      50% + $d*math.cos($i*360deg/$n)
      50% + $d*math.sin($i*360deg/$n)
      of 1% cw,comma);
  } 
  clip-path: shape(#{$m});  
}

This one is interesting because using 1% as a radius is kind of strange and not intuitive. In the explanation of the arc command, I said that we have exactly two circles with the given radius that intersect with the two points, but what if the radius is smaller than half the distance between the points? No circles can meet that condition.

This case falls into an error handling where the browser will scale the radius until we can have at least one circle that meets the conditions (yes, it’s defined within the specification). That circle will simply have its radius equal to half the distance between both points. It also means we only have two arcs with the same size (small and large will be equal)

In other words, if you specify a small radius (like 1%), you are telling the browser to find the radius value for you (a lazy but clever move!). This trick won’t work in all the situations but can be handy in many of them so don’t forget about it.

Conclusion

We are done with this first article! You should have a good overview of the arc command and how to use it. I only studied the particular case of drawing circular arcs but once you get used to this you can move to the elliptic ones even if I think you will rarely need them.

Let’s end with a last demo of a heart shape, where I am using the arc command. Can you figure out how to do it before checking my code?

And don’t forget to bookmark my online generators from where you can get the code of the flower shapes and more!

Article Series

]]>
https://frontendmasters.com/blog/creating-flower-shapes-using-clip-path-shape/feed/ 0 5824
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
Full-Bleed Layout with Modern CSS https://frontendmasters.com/blog/full-bleed-layout-with-modern-css/ https://frontendmasters.com/blog/full-bleed-layout-with-modern-css/#comments Mon, 27 Jan 2025 18:10:15 +0000 https://frontendmasters.com/blog/?p=5032 I recently shared a trick on how to create a Full-bleed layout using a few lines of modern CSS code. If you are unfamiliar with such layout see the demo below. In this article we’ll dig deeper into the idea and explain things as we go. 

The main content area is limited to a certain width and centered but a few elements “bleed” to the outside edges, filling the entire page width. There are already a lot of techniques to create such a layout but the one I came up with relies on modern features and only 4 lines of code.

html {
  container-type: inline-size;
}
main {
  --_m: max(1em, 50cqw - 400px/2);
  margin-inline: var(--_m);
}
.full-bleed {
  margin-inline: calc(-1 * var(--_m));
}

You might look at that code and think it’s unreadable and hacky. If so, after we dissect it together, I hope to change your mind. You will soon understand the logic behind it and see it’s actually a rather efficient way of handling this situation. 

Why make <html> a container?

You might be familiar with viewport units such as vw. The use of 100vw essentially means “the width of the browser window” a.k.a the viewport. It’s common to rely on such a metric, but it comes with a drawback: whether or not you have a scrollbar width: 100vw will always give the same result. This is a bit frustrating and sometimes we wind up with unwanted overflow.

Here is a quick demo to illustrate the issue:

The container has a height equal to 500px. If the page is tall enough to show the whole container, everything is fine but once the height gets smaller and we need to scroll the page, another scroll appears at the bottom!

Ideally, we want 100vw to behave differently, but it won’t, so we have to find something else. You’d think the advent of the dvw unit would have been an opportunity to fix this, but it does not.

Making the <html> element a container is one solution because it will unlock the ability to query the width of the html (instead of the whole page) by using 100cqw. Since the <html> element is the uppermost element of the page and it is a block-level element it will always (unless you override this behavior) have the width of the page while considering the scrollbar. In other words, 100cqw will get smaller when a scrollbar appears on the page — which is perfect!

Here is the previous demo using 100cqw instead of 100vw. No more issue this time!

Instead of relying on 100vw like most of the techniques, I will use 100cqw which is slightly better and for this, I have to make the <html> element a container. 

I am deliberately skipping the explanation of what “container” means to avoid making this article too long. If you are unfamiliar with this, it refers to the relatively recent ability in CSS to do “container queries”. Check out this article.

What about margin?

If I told you we need a container with a max-width which is centered horizontally, you will like intuitively do this:

main {
  max-width: 400px;
  margin-inline: auto; /* or: margin: 0 auto; */
}

This is fairly simple, efficient, and people with basic CSS experience will understand it. I’d advise you to keep doing this, but we can also do the same using only margin like I detail in my post max-width + centering with one instruction.

If the container needs to have a max-width equal to w, then the remaining space on both sides is equal to 50% - w/2 where 50% refers to the parent width. If we define the margin using that space, we have what we want. 

It may be a bit counter-intuitive, but it’s logical. We either define the width and tell the browser to calculate the margin for us (using auto), or we do the opposite and define the margin then the browser will calculate the width for us. Unlike margin, the default value of width is already auto so defining the margin is enough.

main {
  margin-inline: max(0px, 50% - 600px/2);
}

I am using max() to avoid getting negative values on small screens. In other words, I am clamping the value to 0.

Let’s suppose that the margin is equal to 100px at some points. If an element inside the container has a margin equal to the opposite (i.e. -100px) it will negate the previous margin and extend to the full width of the container.

Do you see the trick now? The same margin used to set the max-width and center the main container is also used (with a negative sign) on the “full-bleed” elements to make them “bleed” outside the container and extend to the edge of the screen!


main {
  --_m: max(0px, 50% - 600px/2);
  margin-inline: var(--_m);
}
.full-bleed {
  margin-inline: calc(-1 * var(--_m));
}

The margin is defined as a custom property and is used twice: on the main container, and with a negative sign on the “full-bleed” class.

It looks perfect but the above code won’t work! Be careful — I’ve tricked you!

We are using percentage values which means the reference for the calculation is not the same for both elements so both margins will never be equal (I know: percentages are always frustrating). 

I think you know what will be the solution, right? We rely on the cqw unit we detailed previously to make sure the reference is always the same (the width of the page while considering the scrollbar). 

With that our puzzle is complete! A full-bleed layout with a simple code:

html {
  container-type: inline-size;
}
main {
  --_m: max(0px, 50cqw - 600px/2);
  margin-inline: var(--_m);
}
.full-bleed {
  margin-inline: calc(-1 * var(--_m));
}

As a bonus, you can replace the 0px inside the max() function with any value and it act as a “minimum margin”. That is, the margin that your main container will have on small screens.

Another way to write the code

Now that we know how it works, let’s re-write the code in a bit more friendly-to-read way:

html {
  container-type: inline-size;
}
main {
  --w: 600px; /* the max-width */
  --m: 1em;   /* margin on small screen */
  
  margin-inline: max(   var(--m),50cqw - var(--w)/2);
}
.full-bleed {
  margin-inline: min(-1*var(--m),var(--w)/2 - 50cqw);
  /* same as
  margin-inline: calc(-1*max(var(--m),50cqw - var(--w)/2))  
  */
}

This is slightly better because all you have to do is update a few custom property values. With this syntax, we can also create more variations where we can update the margin behavior of the “full-bleed” elements.

If, for example, we replace -1*var(--m) with 0px

.full-bleed {
  margin-inline: min(0px, var(--w)/2 - 50cqw);
}

The elements will have a margin equal to --m on small screens. In other words, the elements lose their “bleed-out” behavior on small screens.

I came up with a total of four variations (including the default one):

.full-bleed-1 {
  margin-inline: min(-1*var(--m),var(--w)/2 - 50cqw);
}
.full-bleed-2 {
  margin-inline: min(-1*var(--m),var(--w)/2 - 50cqw + var(--m));
}
.full-bleed-3 {
  margin-inline: min(        0px,var(--w)/2 - 50cqw);
}
.full-bleed-4 {
  margin-inline: min(        0px,var(--w)/2 - 50cqw + var(--m));
}

Here is a demo to illustrate the behavior of each one. Make it full screen and resize it to understand each variation.

Restricting the Content of the Full Bleed Section to the Same Width as the Rest of the Main Content

Let’s end with one last demo where it’s only the background color that extends to the edge of the screen. The content is still restricted to the same maximum width as everything else. This is a particular case of full-bleed layout where we don’t need to mess with margin and complex calculation, and has an entirely different trick up it’s sleeve. I’ll leave it to you to poke at the code and see it.

This demo relies on a single line of code where I’m using the outset feature of border-image to have overflowing coloration on both sides. The border-image property is a bit tricky to grasp, but I have a detailed article if you want to learn more about it: “The Complex but Awesome CSS border-image Property“.

Conclusion

Cool, right? Not only have we created a full-bleed layout with compact code but we can easily adjust it to control the margin behavior of the elements. Can you think of other variations? I am sure we could tweak the formulas to have other useful and interesting behaviors. The comment section is down below if you have some good ideas.

]]>
https://frontendmasters.com/blog/full-bleed-layout-with-modern-css/feed/ 3 5032
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