Anchor positioning and scroll-driven animations are among of the most popular and exciting CSS features of 2024. They unlock a lot of possibilities, and will continue to do so as browser support improves and developers get to know them.
Here is a demo of a custom range slider where I am relying on such features.
This whole UI is a semantic HTML <input type="range">
, with another semantic <output>
element showing off the current value, along with quite fancy CSS.
Intuitively, you may think there is a JavaScript code somewhere gathering the value of the input “on change” and updating the position/content of the tooltip. As for the motion, it’s probably a kind of JavaScript library that calculates the speed of the mouse movement to apply a rotation and create that traction illusion.
Actually, there is no JavaScript at all.
It’s hard to believe but CSS has evolved in a way that we can achieve such magic without any scripts or library. You will also see that the code is not that complex. It’s a combination of small CSS tricks that we will dissect together so follow along!
At the time of writing, only Chrome (and Edge) have the full support of the features we will be using.
Prerequisites
First, let’s start with the HTML structure:
<label>
Label
<input type="range" id="one" min="0" max="120" value="20">
<output for="one" style="--min: 0;--max: 120"></output>
</label>
Code language: HTML, XML (xml)
An input
element and an output
element are all that we need here. The label part is not mandatory for the functionality, but form elements should always be labelled and you need a wrapper element anyway.
I won’t detail the attributes of the input
element but note the use of two CSS variables on the output
element that should have the same values as the min
and max
attributes.
In addition to the HTML code, I am going to consider the styling of the range slider and the tooltip as prerequisites as well. I will mainly focus on the new features and skip most of the aesthetic parts, although I have covered some of those aspects in other articles, like here where I detail the styling of the range slider.
As for the tooltip, I have a big collection of 100 different tooltip shapes and I am going to use the #41 and #42. I also have a two-part article detailing the creation of most of the tooltips.
You don’t need the fancy styled tooltip output, nor do you need the custom styling of the range slider itself, it’s just fun and offers some visual control you might want. Here’s a “naked” demo without all that:
The Tooltip Position
The first thing we are going to do is to correctly place the tooltip above (or below) the thumb element of the slider. This will be the job of Anchor positioning and here is the code:
input[type="range" i]::-webkit-slider-thumb {
anchor-name: --thumb;
}
output {
position-anchor: --thumb;
position: absolute;
position-area: top; /* or bottom */
}
Code language: CSS (css)
That’s all! No more than four CSS declarations and our tooltip is correctly placed and will follow the movement of the slider thumb.
Anchor positioning is an upgrade of position: absolute
here. Instead of positioning the element relatively to an ancestor having position: relative
we can consider any element on the page called an “anchor”. To define an anchor we use anchor-name
with whatever value you want. It’s mandatory to use the dashed indent notation like with custom properties. That same value can later be used within the absolute element to link it with the “anchor” using position-anchor
.
Defining the anchor is not enough, we also need to correctly position the element. For this, we have the position-area
.
The
refposition-area
CSS property enables an anchor-positioned element to be positioned relative to the edges of its associated anchor element by placing the positioned element on one or more tiles of an implicit 3×3 grid, where the anchoring element is the center cell.
Here is an online tool to visualize the different values.
We’re using position-area: top
on the <output>
, and a bottom
class flips that to position-area: bottom
to re-position it and make the design work below.
Here is the demo so far:
Hmmmm, there is an issue! Both tooltips are linked to the same thumb. This is understandable, because I used the same anchor name so the first one will get ignored.
Use a different name, you say, and that’s correct but it’s not the optimal solution. We can still keep the same name and instead, limit the scope using anchor-scope
.
label {
anchor-scope: --thumb;
}
Code language: CSS (css)
The above code should limit the scope of the anchor --thumb
to the label
element and its descendant. In other words, the anchor cannot be seen outside the label
element.
Another fix is to add position: relative
to label
. I won’t detail how it works but it has to do with the creation of a containing block.
Hmmmmm. We have fixed the scoping problem but the position of the tooltip is still not good. If you move the thumb to the edges, the tooltip is no longer following. It’s limited to the boundary of the slider. It’s kind of strange, but it’s by design.
By adding position: relative
we create a containing block for the tooltip and we trigger the following behavior described by the specification:
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. This behavior makes it more likely that positioned boxes remain visible and within their intended bounds, even when their containing block ends up smaller than anticipated.
To fix this, we can use negative margin margin-inline: -4em
or a negative inset inset: 0 -4em
to allow the element to go outside the boundary
Of course, it applies to only this particular demo. You may need a different fix in different situations but at least you are aware of this quirk so don’t forget about it!
The Tooltip Content
Now that our tooltip is correctly positioned, let’s move to the content. This is where scroll-driven animations enter the story. I know what you are thinking: “We have nothing to scroll, so how are we going to use scroll-driven animations?”
If you read the MDN page you will find something called a “view progress timeline”:
You progress this timeline based on the change in visibility of an element (known as the subject) inside a scroller. The visibility of the subject inside the scroller is tracked as a percentage of progress — by default, the timeline is at 0% when the subject is first visible at one edge of the scroller, and 100% when it reaches the opposite edge.
This is perfect for us since we have a thumb (the subject) that moves inside the input (the scroller) so we don’t really need to have anything else to scroll.
We start by defining the timeline as follows:
input {
overflow: hidden; /* or `auto` */
}
input[type="range" i]::-webkit-slider-thumb {
view-timeline: --thumb-view inline;
}
Code language: CSS (css)
Similar to what we did with the anchor, we give a name and the axis (inline
) which is the horizontal one in our default writing mode. Then, we define overflow: hidden
on the input element. This will make the input our scroller while the thumb is the subject.
If you forget about the overflow (so easy to forget!), another element will get used as the scroller, and won’t really know which one, and nothing will work as expected. Always remember that you need to define the subject using view-timeline
and the scroller using overflow
. I will repeat it again: don’t forget to define overflow on the scroller element!
Next, we define the animation:
@property --val {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
label {
timeline-scope: --thumb-view;
}
output {
animation: range linear both;
animation-timeline: --thumb-view;
}
@keyframes range {
0% { --val: var(--max) }
100% { --val: var(--min) }
}
Code language: CSS (css)
Let’s start with timeline-scope
. This is yet another scoping issue that will give you a lot of headaches. With anchor positioning, we saw that an anchor is by default available everywhere on the page and we have to limit its scope. With scroll-driven animations, the scope is limited to the element where it’s defined (the subject) and its descendant so we have to increase the scope to make it available to other elements. Two different implementations but the same issue.
Never ever forget about scoping when working with both features. Sometimes, everything is correctly defined and you are only missing timeline-scope
or position: relative
somewhere.
Next we define an animation that animates an integer between the min
and max
variables, then link that animation with the timeline we previously defined using animation-timeline
.
Why the max
is at 0% and the min
at 100%? Isn’t that backwards, you ask?
Intuitively, we tend to think “from left to right” but this looks like it’s “from right to left”. To understand this, we need to consider the “scroll” part of the feature.
I know that we don’t have scrolling in our case but consider the following example to better understand.
When you scroll the container “from left to right” you have a red circle that moves “from right to left”. We still have the “from left to right” behavior but it’s linked to the scroll. As for the content, it will logically move in the opposite direction “from right to left”.
When the scroll is at the left, the element is at the right and when the scroll is at the right, the element is at the left. The same logic applies to our thumb even if there is nothing to scroll. When the thumb is at the right edge, this is our 0%
state and we need to have the max
value there. The left edge will be the 100%
state and it’s the min
value.
The last step is to show the value using a pseudo-element and counter()
output::before {
content: counter(num);
counter-reset: num var(--val);
}
Code language: CSS (css)
And we are done!
Wait a minute, the values aren’t good! We are not reaching the min and max values. For the first slider, we are supposed to go from 0
to 120
but instead, we have 9
and 111
.
Another trick related to the scroll part of the feature and here is a figure to illustrate what is happening:
The movement of the thumb is limited to the input container (the scroller) but the 0%
and 100%
state are defined to be outside the scroller. In our case, the subject cannot reach the 0%
and 100%
since it cannot go outside but luckily we can update the 0%
and 100%
state:
We can either use animation-range
to make both states inside the container:
output {
animation: range linear both;
animation-timeline: --thumb-view;
animation-range: entry 100% exit 0%;
}
Code language: CSS (css)
Or we consider view-timeline-inset
with a value equal to the width of the thumb.
input[type="range" i]::-webkit-slider-thumb{
anchor-name: --thumb;
view-timeline: --thumb-view inline;
view-timeline-inset: var(--s); /* --s is defined on an upper element and is used to define the size of the thumb */
}
Code language: CSS (css)
The first method seems better as we don’t have to know the size of the thumb (the subject) but keep in mind both methods. The view-timeline-inset
property may be more suitable in some situations.
Now our slider is perfect!
A lot of stuff to remember, right? Between the scoping issues, the range we have to correct, the overflow we should not forget, the min
that should be at 100%
and max
that should be at 0%
, etc. Don’t worry, I feel the same. They are new features with new mechanisms so it requires a lot of practice to get used to them and build a clear mental model. If you are a bit lost, that’s fine! No need to understand everything at once. Take the time to play with the different demos, read the doc of each property, and try things on your own.
Adding Motion
Now let’s move to the fun part, those silly wobbly animations. A tooltip that follows the thumb with dynamic content is good but it’s even better if we add some motion to it.
You may think this is gonna be the hardest part but actually it’s the easiest one, and here is the relevant code:
@property --val {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
@property --e {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
output {
animation: range linear both;
animation-timeline: --thumb-view;
animation-range: entry 100% exit 0%;
}
output:before {
content: counter(num);
counter-reset: num var(--val);
--e: var(--val);
transition: --e .1s ease-out;
rotate: calc((var(--e) - var(--val))*2deg);
}
@keyframes range {
0% { --val: var(--max) }
100% { --val: var(--min) }
}
Code language: CSS (css)
We add a new CSS variable --e
with a number type. This variable will be equal to the --val
variable. Until now, nothing fancy. We have two variables having the same value but one of them has a transition
. Here comes the magic.
When you move the thumb, the animation will update the --val
variable inside the output element. The pseudo-element will then inherit that value to update the content and also update --e
. But since we are applying a transition to --e
, it will not have an instant update but a smooth one (well, you know how transitions work!). This means that for a brief moment, both --e
and --val
will not be equal thus their difference is different from 0. We use that difference inside the rotation!
In addition to this, the difference can get bigger if you move the thumb fast or slow. Let’s suppose the current value is equal to 5
. If you move the thumb rapidly to the value 50
, the difference will be equal to 45
hence we get a big rotation. If you move to the value 7
, the difference will be equal to 2
and the rotation won’t be that big.
Here is the full demo again so you can play with it. Try different speeds of movement and see how the rotation is different each time.
If you want to dig more into this technique and see more examples I advise you to read this article by Bramus.
Another Example
Let’s try a different idea.
This time, I am adjusting the tooltip position (and its tail) to remain within the horizontal boundary of the input element. Can you figure out how it’s done? This will be your homework!
For the tooltip part, I already did the job for you. I will redirect you again to my online collection where you can get the code of the tooltip shape. Within that code, I am already defining one variable that controls the tail position.
Conclusion
CSS is cool. A few years ago, doing such stuff with CSS would have been impossible. You would probably need one or two JavaScript libraries to handle the position of the tooltip, the dynamic content, the motion, etc. Now, all it takes is a few lines of CSS.
It’s still early to adopt those features and include them in real projects but I think it’s a good time to explore them and get an overview of what could be done in the near future. If you want more “futuristic” experimentation make sure to check my CSS Tip website where I regularly share cool demos!
Wow! I need to refresh my knowledge of CSS. I never expected such magic without JS. Very nice effect.