Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Tue, 04 Nov 2025 00:46:28 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Split Effects with no Content Duplication https://frontendmasters.com/blog/split-effects-with-no-content-duplication/ https://frontendmasters.com/blog/split-effects-with-no-content-duplication/#respond Wed, 11 Sep 2024 19:18:31 +0000 https://frontendmasters.com/blog/?p=3782 recent post here lead me to another called The Magic of Clip Path by Emil Kowalski, which focuses on the inset() basic shape in particular. While I agree that clip-path is a very useful property and the inset() basic shape is underrated and underused on the web, most of the use case examples in the article are far from ideal as they rely on content duplication, which can come at a maintenance, performance, and accessibility cost, not to mention that some of them break in some scenarios.

In this article, I’ll be showing how to get the same effects with no content duplication.

Comparison Sliders

Emil creates this with two different images (the before and the after) stacked one on top of the other, the top one being clipped, plus a button for the draggable line control.

Using a button for the draggable line somehow doesn’t feel right to me, but I’m no expert when it comes to accessibility, so we’ll be focusing on how to do this with a single image, though this is the one example where I can see some advantages to using two images instead of one.

This kind of comparison slider is something I once explained in detail in another article some years back. The basic idea used there is the following: the original image (the “before”) is a background layer of a slider whose thumb is the draggable split line. The original image background layer is blended with another which only covers the progress area between the lateral edge of the track and the current thumb position. The result of the blending operation is the “after”.

In my old article, the result of the blending operation was the negative of the image:

Here’s a fancier version of it:

But we can also have other effects, for example desaturating an image (black and white photography effect).

This demo needs a single HTML element (input[type=range]), less than 20 CSS declarations (and that’s with having to duplicate a bunch of them for the -webkit- and -moz- cases) and under 100 bytes of JS (without even bothering to minify it).

The trick here is to use a color blend mode, which takes the the hue and the saturation of the top layer (transparent before the thumb and any grey after) and the luminosity (which is not the ‘L’ in HSL, that one stands for lightness, but still close enough in a lot of cases) of the bottom layer (the image).

The saturation of any grey is 0 and zero saturation makes the hue irrelevant (if you think about the HSL bicone, the saturation is the horizontal distance from the vertical axis, so the greys are on the vertical axis, where the rotation around it, which gives us the hue, doesn’t matter anymore).

That is, the color blend mode with any grey top layer zeroes the saturation of the result, giving us a fully desaturated image (as if we applied filter: grayscale(1) on it).

We can also make a fully desaturated image monochrome. This only requires a couple of tiny changes from the previous demo: replacing the grey with a dark blue (or anything with non-zero saturation, really) and using a black and white image.

Or why just monochrome it when we can duotone it?

Here, we just switched to an exclusion blend mode. How this works in the back is something I explained in a lot of detail in the blend modes article.

This technique also has the advantage of only needing a minimal amount of JS. All the JS does here is to update one custom property --val when the slider thumb gets dragged. That’s it!

However, the amount of effects we can achieve by blending is limited and, since I wrote that article, I’ve changed my mind about the image as a background approach and I don’t like it as much anymore nowadays. I’d rather have an actual img element there, which can get a proper right click menu and a proper alt text (even though the sliders in the previous examples have a label for screen readers which explains the changing image effect on dragging the thumb).

So the more flexible and overall better approach I’d go for nowadays involves an img element and an input[type=range] inside a wrapper kind of structure. On the CSS side, I’d use a backdrop-filter (which opens the door to endless possibilities) instead of blending. The JS remains the same, we only need it to update the same custom property as before.

The trick is to make the wrapper a grid container with a single grid cell, stack and stretch inside this cell the img, then a wrapper pseudo-element and finally the input[type=range] on top of both.

Both the pseudo-element and the slider get pointer-events: none, so that right click brings up a menu that allows us to open the image in a new tab, save it, copy it and so on. Note that this needs to get reverted on the slider’s thumb (by setting pointer-events: auto) so we can drag it. The pseudo-element gets clipped to just the area between the slider’s edge and the thumb’s vertical midline. It also has a backdrop-filter. This creates the desired effect on the part of the image underneath the area this pseudo-element is clipped to.

The demo above shows a blur effect, but we have unlimited possibilities here, as we can also chain CSS filters or even use SVG ones. We can make our image grainy, introduce chromatic aberration or swap two channels… the sky’s the limit!

One caveat though: as cool as in the browser image filter effects are, we only have access to the original image via the right click menu. We cannot save or copy the result we get after applying the filter this way. For the situation when we want that, stacking the filtered and clipped version of the image on top of the original is probably the better idea, even if we have to load two images.

Split Text

Emil showcases an example similar to the one used by Vercel here. This duplicates the text and clips the version on top. It also breaks on small viewports.

But we can do something like this with no text duplication whatsoever, which also allows us to avoid such problems, regardless of the viewport size.

This can be seen in the demo below where you can drag the separator line:

The trick here is to put the text fill, the text stroke and the progress area each on one RGB channel. In my demo, the text fill uses the blue channel, the text stroke uses the red channel and the progress area uses the green channel. Note that the progress area is created using a full coverage pseudo on the element containing the text and that this pseudo is blended with its parent.

p {
  position: relative;
  color: #00f;
  -webkit-text-stroke: #f00 4px;
  isolation: isolate;

  &::after {
    position: absolute;
    inset: 0;
    background: linear-gradient(#0f0 var(--prc), #000 0);
    mix-blend-mode: lighten;
    pointer-events: none;
  }
}

The --prc stop position is the progress value of the slider in %. The higher up we pull it, the lower the value and the other way around.

The isolation property ensures the pseudo is only blended with its parent, but not with whatever backdrop may be behind its parent as well. pointer-events: none on the pseudo ensures we can click and select the text underneath.

The result so far looks like this:

Intermediate result.

You can see that here, each of the three RGB channels are either zeroed or maxed out (0 or 1). The values for the red channel (text stroke) and the blue channel (text fill) are mutually exclusive, it’s just the green channel (progress area) that can mix with the other two.

We then apply an SVG filter. Here, we combine two concepts I’ve talked about this year before: using RGB channels as alpha masks and painting the graphic we extract using an RGB value (using one of the two ways I did for these monojicons).

Our filter extracts the intersection between the blue channel (the text fill) and the green channel (the progress area) – that is, the text fill within the limits of the progress area – and paints it white.

The intersection between the text fill and the progress area, painted in white.

This is pretty much like creating an alpha mask that makes opaque the area where both the blue channel and the green channel are maxed out. And at the same time, makes transparent the area where at least one of the two is 0.

The filter also extracts the difference between the red channel (the text stroke) and the green channel (the progress area) – that is, the text stroke outside the progress area, then paints it using a variable (which can be either currentColor or a custom property, var(--c-neon), for example).

The difference between the text stroke and the progress area, painted in neon blue.

Finally, we put these two together and we have the result!

There are some other minor polishing tweaks in the demo, but this is the main idea. It’s very similar to other text split demos with no duplication I’ve made (as seen in this CodePen collection), the only difference being that in this case the split line isn’t fixed, but depends on a value that changes when dragging the slider.

The CodePen collection.

Tabs Transition

This is another effect that Emil achieves by duplicating the whole navigation content.

As you might suspect, we can do without duplication!

What is going on here?

First off, each nav item has an index --i, whereas the nav itself has a current index --k, equal to the index --i of the currently selected item. The only JS necessary here is to update the value of --k on the nav to match the value --i of the item we’ve just clicked/selected (works with Tab + Enter too).

We want the currently selected item to have a highlight — that is, we want to have a highlight over the item where the difference between --i and --k is 0. In order to know from which direction this highlight needs to move when we change the selected item and --k changes value, we need to also get the sign of this difference between --i and --k. Since no matter which item is selected, both --i and --k are set to integer values, we use this formula that I explained in detail in an older article to compute the sign:

--sgn: clamp(-1, var(--i) - var(--k), 1)

Now, you may be wondering why in the world still use this when we now finally have the sign() function supported cross-browser (almost, it’s still behind a flag in Chrome) and the answer is that… well, we’re calling it “sign”, but that’s not exactly what we want. I said above that both --i and --k are set to integer values and, while that is true, --k also takes non-integer values when it smoothly transitions from the integer value it previously had to the one it’s currently set to.

The sign() function jumps from -1 to 0, then to 1 and we don’t want that jump. We don’t want this:

The graph of sign(x) has discontinuity at 0.
sign(x) graph

We want this:

The graph of clamp(-1, x, 1) is continuous.
clamp(-1, x, 1) graph

Next we want to know if the highlight is outside of an item or over that item, meaning that item is selected. The highlight is outside of an item if --sgn is non-zero:

--out: abs(var(--sgn))

That is, if --sgn is either -1 or 1, then --out is 1, the highlight is outside the item, meaning the item is not selected. If --sgn is 0, then --out is 1, the highlight is not outside the item, but instead is over it, the item is selected.

Note that with sign() and abs() being the final two mathematical functions still behind the Experimental Web Platform features flag in Chrome, we need to wrap the above in a @supports and also use a fallback:

--out: max(var(--sgn), -1*var(--sgn));

There’s still one more thing we need to compute here and that’s whether we need to move the highlight towards the positive direction of the x axis if we were to select an item of index --i that’s currently not selected:

--bit: round(.5*(1 + var(--sgn)))

We’d need to move in the positive direction of the x axis if we were to select an item of an index --i bigger than --k, that is, if --sgn is 1. In this case, --bit computes to 1. Othwerwise, if we wouldn’t need to move in the positive direction of the x axis but in the negative one, then that means --i is smaller than --k, which means --sgn is -1, so --bit computes to 0.

We create the highlight with a pseudo-element, which is absolutely positioned to cover its entire parent. Okay, but we want this highlight only for the currently selected item, so we clip it to nothing for all the others, that is, for all those where --out computes to 1.

And we know whether to clip it from the right or from the left based on the --bit value. If --bit is 1, we clip from the right (the positive direction of the x axis). Otherwise, we clip from the left. This is the clip-path and the lateral offset values:

--r: calc(var(--out)*var(--bit)*100%);
--l: calc(var(--out)*(1 - var(--bit))*100%);
clip-path: inset(0 var(--r) 0 var(--l));

The final ingredient is to register (as '<number>') and transition --k.

You can see a basic version of this below:

Now you may see a teeny tiny gap in the highlight during the transition.

Screenshot. Shows a tiny vertical gap during the transition.
The problem.

This is due to pixel rounding and the way to fix it is to ensure the pseudo-element highlight doesn’t have a width that might get rounded down to an integer number of pixels.

inset: 0 -.5px

That’s it!

Okay, but what about the rounded corners and the text color change on intersecting the highlight? For that, we need an SVG filter that achieves two things. One, the blobby look and two, something very similar to the one in the earlier text split example, a different look for the text where it intersects the highlight blob versus where it doesn’t. We also want to have a different look for the :hover:focus state outside the blob.

Just like in the text split case, we use a separate RGB channel for each component. The red channel is used for the highlight, the blue channel for the regular text and the green channel for the text in the :hover/:focus case.

Screenshot. Shows the nav with different parts on different channels. Normal (not focused/ hovered) text is on the blue channel. Hovered/ focused text is on the blue channel. The highlight is on the red channel. Since the layers are blended using the lighten blend mode, the text intersecting the highlight looks fuchsia/ magenta (has both the red and bluue channels maxed out).
Intermediate/pre-filter result.

Then in the SVG filter, we first extract the highlight out of the red channel, paint it blue and turn its shape into a blob. Then we extract the text out of the blue and green channels and paint it either grey or blue depending on what channel it’s on. We place the blob on top of it and on top of that, we put the intersection between the text and the blob, painted in white.

Theme Switch Transition

Emil shared a version that duplicates the entire page, one version being in light mode and the other in the dark mode, with the top one having a clip-path on it.

Back when :has() was still a new feature in late 2022, I started toying with a bunch theme switch effects using it and one of them produced a result very similar to this, but without duplicating any content.

Let’s take a quick look at the idea behind!

It’s not very far from what I’m doing in this bubble theme switch (which I’ve explained in detail in the Pen description), but it allows for more control than simply inverting what’s on the page.

We have a custom property --dark that’s 0 in the light theme case and 1 in the dark theme case.

body {
  --dark: 0;
 
  &:has(#dark:checked) { --dark: 1 }
}

We make the page background a CSS gradient with background-attachment: fixed and depending on the --dark custom property via a percentage --perc, which we register as '<length-percentage>' so it can be transitioned when we switch the theme.

body {
  /* same as before */
  --perc: calc(var(--dark)*100%);
  background: 
    linear-gradient(90deg, #333 var(--perc), #ddd 0) fixed;
  transition: --perc .65s
}

We do something similar for the text itself. In this case, we also need to set the color property to transparent and clip its background to text. This isn’t super ideal as we can end up having to set such a background clipped to text to a lot of elements on the page, but oh, well…

p, label {
  background: 
    linear-gradient(90deg, #ddd var(--perc), #333 0) text fixed
}

Click on either “dark” or “light” below:

This is the basic idea behind. There are two big issues with it.

One, the swipe direction changes (it goes from right to left) when we switch back from the dark theme to the light one. We want the swipe transition to always go from left to right. We can fix this by using an angle --ang that depends on the value of the --dark switch. This isn’t the best solution as it limits us to a linear swipe effect, but we’ll stick to it for now and come back to this problem later.

--sign: sign(var(--dark) - .5);
--ang: calc(var(--sign)*90deg);

To this angle --ang, we may add another one that gives us the direction of the swipe.

Two, it doesn’t give any indication about whether any of these controls is focused or hovered (for example an outline) and there are no special styles for the currently selected one, but we can fix that using the DRY switching technique (with --hov and --sel switches) plus color-mix() to further simplify things.

The same idea applies to all text and links.

There are a couple of issues here.

The first is that we have these ugly edges around the link letters due to the fact that the link background clipped to text is placed on top of the paragraph background clipped to the same text and the latter contrasts with the page background even more.

There are a couple of pretty straightforward fixes here. One by isolating the paragraph and then applying a hard-light blend mode on the link and another by using slightly thicker text for the link. For example, with a font like REM, we can give the normal paragraph text a font-weight of 300 and the links a font-weight of 400.

The second is that when the background gets clipped to text, that doesn’t also include the text-decoration (underline, for example).

An easy solution for this would normally be to add another background of limited vertical size at the bottom, but this doesn’t work here due to the fixed nature of the background.

So we’re forced to use a pseudo and make links inline-block or wrap each link’s text content in a span. Each of these comes with some complications of its own, but oh, well…

In the future, being able to clip the fixed background to text and to a bottom border should do the trick without the need for the extra pseudo-element hack (see this proposal by Lea Verou).

Also, for every element that needs to have both text content and a background (like a button, for example!)… some bad news! Because of a Firefox bug old enough to go to school, we need to either make that element inline-block like we did in the links scenario and use a pseudo or wrap that text content in an inner span. Or, in order to avoid the problems that these two methods come with (and maybe introduce some performance ones instead), we could use an SVG filter. That’s pretty much what we have to do for a lot of input elements (like input[type=button]) anyway.

Okay, but what if we want to have a patterned background? Or what if we want a more interesting link hover effect, for example a XOR one? Or other kinds of XOR effects, for example one on a header? Blending (the difference blend mode in particular) to the rescue!

What about having some gradients on the page? For example, in the case of gradient buttons. That can be done using the same trick of putting the text, the gradient of the button and the gradient determining the progress of the theme swipe transition each on a different RGB channel. Then we use an SVG filter to extract the gradients and text for each of the two themes, paint them as desired and resolve how much of each is shown based on the progress of the theme swipe transition.

You may remember I said something about being tied to linear swipes here. But we can fix that in order to also have radial or conic ones!

First, we need to have two swipe percentage values: one that changes instantly when selecting another theme and another one that transitions smoothly. We only register the second one (--perc-ani).

--dark: 0;
--perc-fix: calc(var(--dark)*100%);
--perc-ani: calc(var(--dark)*100%);

We also compute a direction or sign value, whatever you want to call it. This is 1when we’ve switched from the light theme to the dark one (--dark is 1) and -1 otherwise (--dark got switched to 0).

--sign: sign(var(--dark) - .5)

Then we compute the progress of the swipe. This always smoothly goes from 0% to 100% over the course of the swipe that changes the theme, regardless of whether we’re switching from the light to the dark theme (--sign is 1--perc-fix has switched to 100% and --perc-ani transitions from 0% to 100%) or from the dark to the light theme (--sign is -1--perc-fix has switched to 0% and --perc-ani transitions from 100% to 0%).

--perc: calc(100% - var(--perc-fix) + var(--sign)*var(--perc-ani));

We then use this in the stop list for the gradient (which now can be a radial or a conic one too) transitioned to create the swipe effect:

--list: var(--c1) var(--perc), var(--c0) 0%

The --c0 and --c1 values depend on whether we’re going from the light theme to the dark one (--perc-fix has switched to 100%) or the other way (--perc-fix has switched to 0%):

--c0: color-mix(in srgb, var(--dark) var(--perc-fix), var(--light));
--c1: color-mix(in srgb, var(--dark), var(--light) var(--perc-fix));

And that’s it! This is the technique from my initial demo in this section which allows us to use any kind of gradient for our swipe.

I hope you’ve enjoyed this little ride through the land of fun effects without content duplication!

]]>
https://frontendmasters.com/blog/split-effects-with-no-content-duplication/feed/ 0 3782
Custom Range Slider Using Anchor Positioning & Scroll-Driven Animations https://frontendmasters.com/blog/custom-range-slider-using-anchor-positioning-scroll-driven-animations/ https://frontendmasters.com/blog/custom-range-slider-using-anchor-positioning-scroll-driven-animations/#comments Wed, 21 Aug 2024 14:20:29 +0000 https://frontendmasters.com/blog/?p=3569 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>  

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 */
}

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

ref

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;
}

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 justify-self: unsafe anchor-center;.

When using position-area: top (or bottom), the browser applies a default alignment in the horizontal axis equivalent to justify-self: anchor-center. By adding the unsafe keyword, we allow it to overflow the containing block instead of shifting inside 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;
}

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

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);
}

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%;
}

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 */
}

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

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!

]]>
https://frontendmasters.com/blog/custom-range-slider-using-anchor-positioning-scroll-driven-animations/feed/ 3 3569