slider – CSS-Tricks https://css-tricks.com Tips, Tricks, and Techniques on using Cascading Style Sheets. Fri, 16 Dec 2022 14:58:17 +0000 en-US hourly 1 https://wordpress.org/?v=6.1.1 https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/07/star.png?fit=32%2C32&ssl=1 slider – CSS-Tricks https://css-tricks.com 32 32 45537868 CSS Infinite 3D Sliders https://css-tricks.com/css-infinite-3d-sliders/ https://css-tricks.com/css-infinite-3d-sliders/#comments Fri, 16 Dec 2022 14:58:08 +0000 https://css-tricks.com/?p=375621 In this series, we’ve been making image sliders with nothing but HTML and CSS. The idea is that we can use the same markup but different CSS to get wildly different results, no matter how many images we toss …


CSS Infinite 3D Sliders originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
In this series, we’ve been making image sliders with nothing but HTML and CSS. The idea is that we can use the same markup but different CSS to get wildly different results, no matter how many images we toss in. We started with a circular slider that rotates infinitely, sort of like a fidget spinner that holds images. Then we made one that flips through a stack of photos.

This time around, we’re diving into the third dimension. It’s going to look tough at first, but lots of the code we’re looking at is exactly what we used in the first two articles in this series, with some modifications. So, if you’re just now getting into the series, I’d suggest checking out the others for context on the concepts we’re using here.

CSS Sliders series

This is what we’re aiming for:

At first glance, it looks like we have a rotating cube with four images. But in reality, we’re dealing with six images in total. Here is the slider from a different angle:

Now that we have a good visual for how the images are arranged, let’s dissect the code to see how we get there.

The basic setup

Same HTML as the rest of the sliders we’ve used for the other sliders:

<div class="gallery">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
</div>

And once again, we’re using CSS Grid to place the images in a stack, one on top of another:

.gallery {
  display: grid;
}
.gallery > img {
  grid-area: 1 / 1;
  width: 160px;
  aspect-ratio: 1;
  object-fit: cover;
}

The animation

The logic for this slider is very similar to the circular slider from the first article. In fact, if you check the video above again, you can see that the images are placed in a way that creates a polygon. After a full rotation, it returns to the first image.

We relied on the CSS transform-origin and animation-delay properties for that first slider. The same animation is applied to all of the image elements, which rotate around the same point. Then, by using different delays, we correctly place all the images around a big circle.

The implementation will be a bit different for our 3D slider. Using transform-origin won’t work here because we’re working in 3D, so we will use transform instead to correctly place all the images, then rotate the container.

We’re reaching for Sass again so we can loop through the number of images and apply our transforms:

@for $i from 1 to ($n + 1) {
  .gallery > img:nth-child(#{$i}) {
     transform: 
       rotate(#{360*($i - 1) / $n}deg) /* 1 */
       translateY(50% / math.tan(180deg / $n)) /* 2 */ 
       rotateX(90deg); /* 3 */
  }
}

You might be wondering why we’re jumping straight into Sass. We started with a fixed number of images using vanilla CSS in the other articles before generalizing the code with Sass to account for any number (N) of images. Well, I think you get the idea now and we can cut out all that discovery work to get to the real implementation.

The transform property is taking three values, which I’ve illustrated here:

Showing the three phases of the image slider layout.

We first rotate all the images above each other. The angle of rotation depends on the number of images. For N images, we have an increment equal to 360deg/N. Then we translate all of the images by the same amount in a way that makes their center points meet on the sides.

Showing the stack of images arranged flat in a circle with a red line running through the center point of the images.

There’s some boring geometry that helps explain how all this works, but the distance is equal to 50%/tan(180deg/N). We dealt with a similar equation when making the circular slider ( transform-origin: 50% 50%/sin(180deg/N) ).

Finally, we rotate the images around the x-axis by 90deg to get the arrangement we want. Here is a video that illustrates what the last rotation is doing:

Now all we have to do is to rotate the whole container to create our infinite slider.

.gallery {
  transform-style: preserve-3d;
  --_t: perspective(280px) rotateX(-90deg);
  animation: r 12s cubic-bezier(.5, -0.2, .5, 1.2) infinite;
}
@keyframes r {
  0%, 3% {transform: var(--_t) rotate(0deg); }
  @for $i from 1 to $n {
    #{($i/$n)*100 - 2}%, 
    #{($i/$n)*100 + 3}% {
      transform: var(--_t) rotate(#{($i / $n) * -360}deg);
    }  
  }
  98%, 100% { transform: var(--_t) rotate(-360deg); }
}

That code might be hard to understand, so let’s actually step back a moment and revisit the animation we made for the circular slider. This is what we wrote in that first article:

.gallery {
  animation: m 12s cubic-bezier(.5, -0.2, .5, 1.2) infinite;
}
@keyframes m {
  0%, 3% { transform: rotate(0); }
  @for $i from 1 to $n {
    #{($i / $n) * 100 - 2}%,
    #{($i / $n) * 100 + 3}% { 
      transform: rotate(#{($i / $n) * -360}deg);
    }  
  }
  98%, 100% { transform: rotate(-360deg); }
}

The keyframes are almost identical. We have the same percentage values, the same loop, and the same rotation.

Why are both the same? Because their logic is the same. In both cases, the images are arranged around a circular shape and we need to rotate the whole thing to show each image. That’s how I was able to copy the keyframes from the circular slider and use that same code for our 3D slider. The only difference is that we need to rotate the container by -90deg along the x-axis to see the images since we have already rotated them by 90deg on the same axis. Then we add a touch of perspective to get the 3D effect.

That’s it! Our slider is done. Here is the full demo again. All you have to do is to add as many images as you want and update one variable to get it going.

Vertical 3D slider

Since we are playing in the 3D space, why not make a vertical version of the previous slider? The last one rotates along the z-axis, but we can also move along the x-axis if we want.

If you compare the code for both versions of this slider, you might not immediately spot the difference because it’s only one character! I replaced rotate() with rotateX() inside the keyframes and the image transform. That’s it!

It should be noted that rotate() is equivalent to rotateZ(), so by changing the axis from Z to X we transform the slider from the horizontal version into the vertical one.

Cube slider

We cannot talk about 3D in CSS without talking about cubes. And yes, that means we are going to make another version of the slider.

The idea behind this version of the slider is to create an actual cube shape with the images and rotate the full thing in around the different axis. Since it’s a cube, we’re dealing with six faces. We’ll use six images, one for each face of the cube. So, no Sass but back to vanilla CSS.

That animation is a little overwhelming, right? Where do you even start?

We have six faces, so we need to perform at least six rotations so that each image gets a turn. Well, actually, we need five rotations — the last one brings us back to the first image face. If you go grab a Rubik’s Cube — or some other cube-shaped object like dice — and rotate it with your hand, you’ll have a good idea of what we’re doing.

.gallery {
  --s: 250px; /* the size */

  transform-style: preserve-3d;
  --_p: perspective(calc(2.5*var(--s)));
  animation: r 9s infinite cubic-bezier(.5, -0.5, .5, 1.5);
}

@keyframes r {
  0%, 3%   { transform: var(--_p); }
  14%, 19% { transform: var(--_p) rotateX(90deg); }
  31%, 36% { transform: var(--_p) rotateX(90deg) rotateZ(90deg); }
  47%, 52% { transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg); }
  64%, 69% { transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg) rotateX(90deg); }
  81%, 86% { transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg) rotateX(90deg) rotateZ(90deg); }
  97%, 100%{ transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg) rotateX(90deg) rotateZ(90deg) rotateY(-90deg); }
}

The transform property starts with zero rotations and, on each state, we append a new rotation on a specific axis until we reach six rotations. Then we are back to the first image.

Let’s not forget the placement of our images. Each one is applied to a face of the cube using transform:

.gallery img {
  grid-area: 1 / 1;
  width: var(--s);
  aspect-ratio: 1;
  object-fit: cover;
  transform: var(--_t,) translateZ(calc(var(--s) / 2));
}
.gallery img:nth-child(2) { --_t: rotateX(-90deg); }
.gallery img:nth-child(3) { --_t: rotateY( 90deg) rotate(-90deg); }
.gallery img:nth-child(4) { --_t: rotateX(180deg) rotate( 90deg); }
.gallery img:nth-child(5) { --_t: rotateX( 90deg) rotate( 90deg); }
.gallery img:nth-child(6) { --_t: rotateY(-90deg); }

You are probably thinking there is weird complex logic behind the values I’m using there, right? Well, no. All I did was open DevTools and play with different rotation values for each image until I got it right. It may sound stupid but, hey, it works — especially since we have a fixed number of images and we are not looking for something that supports N images.

In fact, forget the values I’m using and try to do the placement on your own as an exercise. Start with all the images stacked on top of each other, open the DevTools, and go! You will probably end up with different code and that’s totally fine. There can be different ways to position the images.

What’s the trick with the comma inside the var()? Is it a typo?

It’s not a typo so don’t remove it! If you do remove it, you will notice that it affects the placement of the first image. You can see that in my code I defined --_t for all the images except the first one because I only need a translation for it. That comma makes the variable fall back to a null value. Without the comma, we won’t have a fallback and the whole value will be invalid.

From the specification:

Note: That is, var(--a,) is a valid function, specifying that if the --a custom property is invalid or missing, the var()` should be replaced with nothing.

Random cube slider

A little bit of randomness can be a nice enhancement for this sort of animation. So, rather than rotate the cube in sequential order, we can roll the dice so to speak, and let the cube roll however it will.

Cool right? I don’t know about you, but I like this version better! It’s more interesting and the transitions are satisfying to watch. And guess what? You can play with the values to create your own random cube slider!

The logic is actual not random at all — it just appears that way. You define a transform on each keyframe that allows you to show one face and… well, that’s really it! You can pick any order you want.

@keyframes r {
  0%, 3%   { transform: var(--_p) rotate3d( 0, 0, 0,  0deg); }
  14%,19%  { transform: var(--_p) rotate3d(-1, 1, 0,180deg); }
  31%,36%  { transform: var(--_p) rotate3d( 0,-1, 0, 90deg); }
  47%,52%  { transform: var(--_p) rotate3d( 1, 0, 0, 90deg); }
  64%,69%  { transform: var(--_p) rotate3d( 1, 0, 0,-90deg); }
  81%,86%  { transform: var(--_p) rotate3d( 0, 1, 0, 90deg); }
  97%,100% { transform: var(--_p) rotate3d( 0, 0, 0,  0deg); }
}

I am using rotate3d() this time but am still relying on DevTools to find the values that feel “right” to me. Don’t try to find a relationship between the keyframes because there simply isn’t one. I’m defining separate transforms and then watching the “random” result. Make sure the first image is the first and last frames, respectively, and show a different image on each of the other frames.

You are not obligated to use a rotate3d() transform as I did. You can also chain different rotations like we did in the previous example. Play around and see what you can come up with! I will be waiting for you to share your version with me in the comments section!

Wrapping up

I hope you enjoyed this little series. We built some fun (and funny) sliders while learning a lot about all kinds of CSS concepts along the way — from grid placement and stacking order, to animation delays and transforms. We even got to play with a dash of Sass to loop through an array of elements.

And we did it all with the exact same HTML for each and every slider we made. How cool is that? CSS is dang powerful and capable of accomplishing so much without the aid of JavaScript.


CSS Infinite 3D Sliders originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/css-infinite-3d-sliders/feed/ 2 375621
CSS Infinite Slider Flipping Through Polaroid Images https://css-tricks.com/css-infinite-slider-flipping-through-polaroid-images/ https://css-tricks.com/css-infinite-slider-flipping-through-polaroid-images/#respond Fri, 09 Dec 2022 14:26:39 +0000 https://css-tricks.com/?p=375537 In the last article, we made a pretty cool little slider (or “carousel” if that’s what you prefer) that rotates in a circular direction. This time we are going to make one that flips through a stack of Polaroid …


CSS Infinite Slider Flipping Through Polaroid Images originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
In the last article, we made a pretty cool little slider (or “carousel” if that’s what you prefer) that rotates in a circular direction. This time we are going to make one that flips through a stack of Polaroid images.

Cool right? Don’t look at the code quite yet because there’s a lot to unravel. Join me, will ya?

CSS Sliders series

The basic setup

Most of the HTML and CSS for this slider is similar to the circular one we made last time. In fact, we’re using the exact same markup:

<div class="gallery">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
</div>

And this is the basic CSS that sets our parent .gallery container as a grid where all the images are stacked one on top of one another:

.gallery  {
  display: grid;
  width: 220px; /* controls the size */
}
.gallery > img {
  grid-area: 1 / 1;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border: 10px solid #f2f2f2;
  box-shadow: 0 0 4px #0007;
}

Nothing complex so far. Even for the Polaroid-like style for the images, all I’m using is some border and box-shadow. You might be able to do it better, so feel free to play around with those decorative styles! We’re going to put most of our focus on the animation, which is the trickiest part.

What’s the trick?

The logic of this slider relies on the stacking order of the images — so yes, we are going to play with z-index. All of the images start with the same z-index value (2) which will logically make the last image on the top of the stack.

We take that last image and slide it to the right until it reveals the next image in the stack. Then we decrease the image’s z-index value then we slide it back into the deck. And since its z-index value is lower than the rest of the images, it becomes the last image in the stack.

Here is a stripped back demo that shows the trick. Hover the image to activate the animation:

Now, imagine the same trick applied to all the images. Here’s the pattern if we’re using the :nth-child() pseudo-selector to differentiate the images:

  • We slide the last image (N). The next image is visible (N - 1).
  • We slide the next image (N - 1). The next image is visible (N - 2)
  • We slide the next image (N - 2). The next image is visible (N - 3)
  • (We continue the same process until we reach the first image)
  • We slide the first image (1). The last image (N) is visible again.

That’s our infinite slider!

Dissecting the animation

If you remember the previous article, I defined only one animation and played with delays to control each image. We will be doing the same thing here. Let’s first try to visualize the timeline of our animation. We will start with three images, then generalize it later for any number (N) of images.

Diagramming the three parts of the animation.

Our animation is divided into three parts: “slide to right”, “slide to left” and “don’t move”. We can easily identify the delay between each image. If we consider that the first image starts at 0s, and the duration is equal to 6s, then the second one will start at -2s and the third one at -4s.

.gallery > img:nth-child(2) { animation-delay: -2s; } /* -1 * 6s / 3 */
.gallery > img:nth-child(3) { animation-delay: -4s; } /* -2 * 6s / 3 */

We can also see that the “don’t move” part takes two-thirds of the whole animation (2*100%/3) while the “slide to right” and “slide to left” parts take one-third of it together — so, each one is equal to 100%/6 of the total animation.

We can write our animation keyframes like this:

@keyframes slide {
  0%     { transform: translateX(0%); }
  16.67% { transform: translateX(120%); }
  33.34% { transform: translateX(0%); }
  100%   { transform: translateX(0%); } 
}

That 120% is an arbitrary value. I needed something bigger than 100%. The images need to slide to the right away from the rest of the images. To do that, it needs to move by at least 100% of its size. That’s why I went 120% — to gain some extra space.

Now we need to consider the z-index. Don’t forget that we need to update the image’s z-index value after it slides to the right of the pile, and before we slide it back to the bottom of the pile.

@keyframes slide {
  0%     { transform: translateX(0%);   z-index: 2; }
  16.66% { transform: translateX(120%); z-index: 2; }
  16.67% { transform: translateX(120%); z-index: 1; } /* we update the z-order here */
  33.34% { transform: translateX(0%);   z-index: 1; }
  100%   { transform: translateX(0% );  z-index: 1; }  
}

Instead of defining one state at the 16.67% (100%/6) point in the timeline, we are defining two states at nearly identical points (16.66% and 16.67%) where the z-index value decreases before we slide back the image back to the deck.

Here’s what happens when we pull of all that together:

Hmmm, the sliding part seems to work fine, but the stacking order is all scrambled! The animation starts nicely since the top image is moving to the back… but the subsequent images don’t follow suit. If you notice, the second image in the sequence returns to the top of the stack before the next image blinks on top of it.

We need to closely follow the z-index changes. Initially, all the images have are z-index: 2. That means the stacking order should go…

Our eyes 👀 --> 3rd (2) | 2nd (2) | 1st (2)

We slide the third image and update its z-index to get this order:

Our eyes 👀 --> 2nd (2) | 1st (2) | 3rd (1)

We do the same with the second one:

Our eyes 👀 --> 1st (2) | 3rd (1) | 2nd (1)

…and the first one:

Our eyes 👀 --> 3rd (1) | 2nd (1) | 1st (1)

We do that and everything seems to be fine. But in reality, it’s not! When the first image is moved to the back, the third image will start another iteration, meaning it returns to z-index: 2:

Our eyes 👀 --> 3rd (2) | 2nd (1) | 1st (1)

So, in reality we never had all the images at z-index: 2 at all! When the images aren’t moving (i.e., the “don’t move” part of the animation) the z-index is 1. If we slide the third image and update its z-index value from 2 to 1, it will remain on the top! When all the images have the same z-index, the last one in the source order — our third image in this case — is on top of the stack. Sliding the third image results in the following:

Our eyes 👀 --> 3rd (1) | 2nd (1) | 1st (1)

The third image is still on the top and, right after it, we move the second image to the top when its animation restarts at z-index: 2:

Our eyes 👀 --> 2nd (2) | 3rd (1) | 1st (1)

Once we slide it, we get:

Our eyes 👀 --> 3rd (1) | 2nd (1) | 1st (1)

Then the first image will jump on the top:

Our eyes 👀 --> 1st(2) | 3rd (1) | 2nd (1)

OK, I am lost. All the logic is wrong then?

I know, it’s confusing. But our logic is not completely wrong. We only have to rectify the animation a little to make everything work the way we want. The trick is to correctly reset the z-index.

Let’s take the situation where the third image is on the top:

Our eyes 👀 -->  3rd (2) | 2nd (1) | 1st (1)

We saw that sliding the third image and changing its z-index keeps it on top. What we need to do is update the z-index of the second image. So, before we slide the third image away from the deck, we update the z-index of the second image to 2.

In other words, we reset the z-index of the second image before the animation ends.

Diagramming the parts of the animation with indicators for where z-index is increased or decreased.

The green plus symbol represents increasing z-index to 2, and the red minus symbol correlates to z-index: 1. The second image starts with z-index: 2, then we update it to 1 when it slides away from the deck. But before the first image slides away from the deck, we change the z-index of the second image back to 2. This will make sure both images have the same z-index, but still, the third one will remain on the top because it appears later in the DOM. But after the third image slides and its z-index is updated, it moves to the bottom.

This two-thirds through the animation, so let’s update our keyframes accordingly:

@keyframes slide {
  0%     { transform: translateX(0%);   z-index: 2; }
  16.66% { transform: translateX(120%); z-index: 2; }
  16.67% { transform: translateX(120%); z-index: 1; } /* we update the z-order here */
  33.34% { transform: translateX(0%);   z-index: 1; }
  66.33% { transform: translateX(0%);   z-index: 1; }
  66.34% { transform: translateX(0%);   z-index: 2; } /* and also here */
  100%   { transform: translateX(0%);   z-index: 2; }  
}

A little better, but still not quite there. There’s another issue…

Oh no, this will never end!

Don’t worry, we are not going to change the keyframes again because this issue only happens when the last image is involved. We can make a “special” keyframe animation specifically for the last image to fix things up.

When the first image is on the top, we have the following situation:

Our eyes 👀 -->  1st (2) | 3rd (1) | 2nd (1)

Considering the previous adjustment we made, the third image will jump on the top before the first image slides. It only happens in this situation because the next image that moves after the first image is the last image which has a higher order in the DOM. The rest of the images are fine because we have N, then N - 1, then we go from 3 to 2, and 2 to 1… but then we go from 1 to N.

To avoid that, we will use the following keyframes for the last image:

@keyframes slide-last {
  0%     { transform: translateX(0%);   z-index: 2;}
  16.66% { transform: translateX(120%); z-index: 2; }
  16.67% { transform: translateX(120%); z-index: 1; } /* we update the z-order here */
  33.34% { transform: translateX(0%);   z-index: 1; }
  83.33% { transform: translateX(0%);   z-index: 1; }
  83.34% { transform: translateX(0%);   z-index: 2; } /* and also here */
  100%   { transform: translateX(0%);   z-index: 2; }
}

We reset the z-index value 5/6 through the animation (instead of two-thirds) which is when the first image is out of the pile. So we don’t see any jumping!

TADA! Our infinite slider is now perfect! Here’s our final code in all its glory:

.gallery > img {
  animation: slide 6s infinite;
}
.gallery > img:last-child {
  animation-name: slide-last;
}
.gallery > img:nth-child(2) { animation-delay: -2s; } 
.gallery > img:nth-child(3) { animation-delay: -4s; }

@keyframes slide {
  0% { transform: translateX(0%); z-index: 2; }
  16.66% { transform: translateX(120%); z-index: 2; }
  16.67% { transform: translateX(120%); z-index: 1; } 
  33.34% { transform: translateX(0%); z-index: 1; }
  66.33% { transform: translateX(0%); z-index: 1; }
  66.34% { transform: translateX(0%); z-index: 2; } 
  100% { transform: translateX(0%); z-index: 2; }
}
@keyframes slide-last {
  0% { transform: translateX(0%); z-index: 2; }
  16.66% { transform: translateX(120%); z-index: 2; }
  16.67% { transform: translateX(120%); z-index: 1; }
  33.34% { transform: translateX(0%); z-index: 1; }
  83.33% { transform: translateX(0%); z-index: 1; }
  83.34% { transform: translateX(0%); z-index: 2; } 
  100%  { transform: translateX(0%); z-index: 2; }
}

Supporting any number of images

Now that our animation works for three images, let’s make it work for any number (N) of images. But first, we can optimize our work a little by splitting the animation up to avoid redundancy:

.gallery > img {
  z-index: 2;
  animation: 
    slide 6s infinite,
    z-order 6s infinite steps(1);
}
.gallery > img:last-child {
  animation-name: slide, z-order-last;
}
.gallery > img:nth-child(2) { animation-delay: -2s; } 
.gallery > img:nth-child(3) { animation-delay: -4s; }

@keyframes slide {
  16.67% { transform: translateX(120%); }
  33.33% { transform: translateX(0%); }
}
@keyframes z-order {
  16.67%,
  33.33% { z-index: 1; }
  66.33% { z-index: 2; }
}
@keyframes z-order-last {
  16.67%,
  33.33% { z-index: 1; }
  83.33% { z-index: 2; }
}

Way less code now! We make one animation for the sliding part and another one for the z-index updates. Note that we use steps(1) on the z-index animation. That’s because I want to abruptly change the z-index value, unlike the sliding animation where we want smooth movement.

Now that the code is easier to read and maintain, we have a better view for figuring out how to support any number of images. What we need to do is update the animation delays and the percentages of the keyframes. The delay are easy because we can use the exact same loop we made in the last article to support multiple images in the circular slider:

@for $i from 2 to ($n + 1) {
  .gallery > img:nth-child(#{$i}) {
    animation-delay: calc(#{(1 - $i)/$n}*6s);
  }
}

That means we’re moving from vanilla CSS to Sass. Next, we need to imagine how the timeline scale with N images. Let’s not forget that the animation happens in three phases:

Showing the three parts of the animation in a series of lines with arrows.

After “slide to right” and “slide to left”, the image should stay put until the rest of the images go through the sequence. So the “don’t move” part needs to take the same amount of time as (N - 1) as “slide to right” and “slide to left”. And within one iteration, N images will slide. So, “slide to right” and “slide to left” both take 100%/N of the total animation timeline. The image slides away from the pile at (100%/N)/2 and slides back at 100%/N .

We can change this:

@keyframes slide {
  16.67% { transform: translateX(120%); }
  33.33% { transform: translateX(0%); }
}

…to this:

@keyframes slide {
  #{50/$n}%  { transform: translateX(120%); }
  #{100/$n}% { transform: translateX(0%); }
}

If we replace N with 3, we get 16.67% and 33.33% when there are 3 images in the stack. It’s the same logic with the stacking order where we will have this:

@keyframes z-order {
  #{50/$n}%,
  #{100/$n}% { z-index: 1; }
  66.33% { z-index: 2; }
}

We still need to update the 66.33% point. That’s supposed to be where the image resets its z-index before the end of the animation. At that same time, the next image starts to slide. Since the sliding part takes 100%/N, the reset should happen at 100% - 100%/N:

@keyframes z-order {
  #{50/$n}%,
  #{100/$n}% { z-index: 1; }
  #{100 - 100/$n}% { z-index: 2; }
}

But for our z-order-last animation to work, it should happen a bit later in the sequence. Remember the fix we did for the last image? Resetting the z-index value needs to happen when the first image is out of the pile and not when it starts sliding. We can use the same reasoning here in our keyframes:

@keyframes z-order-last {
  #{50/$n}%,
  #{100/$n}% { z-index: 1; }
  #{100 - 50/$n}% { z-index: 2; }
}

We are done! Here’s what we get when using five images:

We can add a touch of rotation to make things a bit fancier:

All I did is append rotate(var(--r)) to the transform property. Inside the loop, --r is defined with a random angle:

@for $i from 1 to ($n + 1) {
  .gallery > img:nth-child(#{$i}) {
    --r: #{(-20 + random(40))*1deg}; /* a random angle between -20deg and 20deg */
  }
}

The rotation creates small glitches as we can sometimes see some of the images jumping to the back of the stack, but it’s not a big deal.

Wrapping up

All that z-index work was a big balancing act, right? If you were unsure how stacking order work before this exercise, then you probably have a much better idea now! If you found some of the explanations hard to follow, I highly recommend you to take another read of the article and map things out with pencil and paper. Try to illustrate each step of the animation using a different number of images to better understand the trick.

Last time, we used a few geometry tricks to create a circular slider that rotates back to the first image after a full sequence. This time, we accomplished a similar trick using z-index. In both cases, we didn’t duplicate any of the images to simulate a continuous animation, nor did we reach for JavaScript to help with the calculations.

Next time, we will make 3D sliders. Stay tuned!


CSS Infinite Slider Flipping Through Polaroid Images originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/css-infinite-slider-flipping-through-polaroid-images/feed/ 0 375537
CSS Infinite and Circular Rotating Image Slider https://css-tricks.com/css-only-infinite-and-circular-image-slider/ https://css-tricks.com/css-only-infinite-and-circular-image-slider/#comments Fri, 02 Dec 2022 14:12:57 +0000 https://css-tricks.com/?p=375308 Image sliders (also called carousels) are everywhere. There are a lot of CSS tricks to create the common slider where the images slide from left to right (or the opposite). It’s the same deal with the many JavaScript libraries out


CSS Infinite and Circular Rotating Image Slider originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Image sliders (also called carousels) are everywhere. There are a lot of CSS tricks to create the common slider where the images slide from left to right (or the opposite). It’s the same deal with the many JavaScript libraries out there that create fancy sliders with complex animations. We are not going to do any of that in this post.

Through a little series of articles, we are going to explore some fancy and uncommon CSS-only sliders. If you are of tired seeing the same ol’ classic sliders, then you are in the right place!

CSS Sliders series

For this first article, we will start with something I call the “circular rotating image slider”:

Cool right? let’s dissect the code!

The HTML markup

If you followed my series of fancy image decorations or CSS grid and custom shapes, then you know that my first rule is to work with the smallest HTML possible. I always try hard to find CSS solutions before cluttering my code with a lot <div>s and other stuff.

The same rule applies here — our code is nothing but a list of images in a container.

Let’s say we’re working with four images:

<div class="gallery">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
</div>

That’s it! Now let’s move to the interesting part of the code. But first, we’re going to dive into this to understand the logic of how our slider works.

How does it work?

Here is a video where I remove overflow: hidden from the CSS so we can better understand how the images are moving:

It’s like our four images are placed on a large circle that rotates counter-clockwise.

All the images have the same size (denoted by S in the figure). Note the blue circle which is the circle that intersects with the center of all the images and has a radius (R). We will need this value later for our animation. R is equal to 0.707 * S. (I’m going to skip the geometry that gives us that equation.)

Let’s write some CSS!

We will be using CSS Grid to place all the images in the same area above each other:

.gallery  {
  --s: 280px; /* control the size */

  display: grid;
  width: var(--s);
  aspect-ratio: 1;
  padding: calc(var(--s) / 20); /* we will see the utility of this later */
  border-radius: 50%;
}
.gallery > img {
  grid-area: 1 / 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
}

Nothing too complex so far. The tricky part is the animation.

We talked about rotating a big circle, but in reality, we will rotate each image individually creating the illusion of a big rotating circle. So, let’s define an animation, m, and apply it to the image elements:

.gallery > img {
  /* same as before */
  animation: m 8s infinite linear;
  transform-origin: 50% 120.7%;
}

@keyframes m {
  100% { transform: rotate(-360deg); }
}

The main trick relies on that highlighted line. By default, the CSS transform-origin property is equal to center (or 50% 50%) which makes the image rotate around its center, but we don’t need it to do that. We need the image to rotate around the center of the big circle that contains our images hence the new value for transform-origin.

Since R is equal to 0.707 * S, we can say that R is equal to 70.7% of the image size. Here’s a figure to illustrate how we got the 120.7% value:

Let’s run the animation and see what happens:

I know, I know. The result is far from what we want, but in reality we are very close. It may looks like there’s just one image there, but don’t forget that we have stacked all the images on top of each other. All of them are rotating at the same time and only the top image is visible. What we need is to delay the animation of each image to avoid this overlap.

.gallery > img:nth-child(2) { animation-delay: -2s; } /* -1 * 8s / 4 */
.gallery > img:nth-child(3) { animation-delay: -4s; } /* -2 * 8s / 4 */
.gallery > img:nth-child(4) { animation-delay: -6s; } /* -3 * 8s / 4 */

Things are already getting better!

If we hide the overflow on the container we can already see a slider, but we will update the animation a little so that each image remains visible for a short period before it moves along.

We’re going to update our animation keyframes to do just that:

@keyframes m {
  0%, 3% { transform: rotate(0); }
  22%, 27% { transform: rotate(-90deg); }
  47%, 52% { transform: rotate(-180deg); }
  72%, 77% { transform: rotate(-270deg); }
  98%, 100% { transform: rotate(-360deg); }
}

For each 90deg (360deg/4, where 4 is the number of images) we will add a small pause. Each image will remain visible for 5% of the overall duration before we slide to the next one (27%-22%, 52%-47%, etc.). I’m going to update the animation-timing-function using a cubic-bezier() function to make the animation a bit fancier:

Now our slider is perfect! Well, almost perfect because we are still missing the final touch: the colorful circular border that rotates around our images. We can use a pseudo-element on the .gallery wrapper to make it:

.gallery {
  padding: calc(var(--s) / 20); /* the padding is needed here */
  position: relative;
}
.gallery::after {
  content: "";
  position: absolute;
  inset: 0;
  padding: inherit; /* Inherits the same padding */
  border-radius: 50%;
  background: repeating-conic-gradient(#789048 0 30deg, #DFBA69 0 60deg);
  mask: 
    linear-gradient(#fff 0 0) content-box, 
    linear-gradient(#fff 0 0);
  mask-composite: exclude;
}
.gallery::after,
.gallery >img {
  animation: m 8s infinite cubic-bezier(.5, -0.2, .5, 1.2);
}

I have created a circle with a repeating conic gradient for the background while using a masking trick that only shows the padded area. Then I apply to it the same animation we defined for the images.

We are done! We have a cool circular slider:

Let’s add more images

Working with four images is good, but it would be better if we can scale it to any number of images. After all, this is the purpose of an image slider. We should be able to consider N images.

For this, we are going to make the code more generic by introducing Sass. First, we define a variable for the number of images ($n) and we will update every part where we hard-coded the number of images (4).

Let’s start with the delays:

.gallery > img:nth-child(2) { animation-delay: -2s; } /* -1 * 8s / 4 */
.gallery > img:nth-child(3) { animation-delay: -4s; } /* -2 * 8s / 4 */
.gallery > img:nth-child(4) { animation-delay: -6s; } /* -3 * 8s / 4 */

The formula for the delay is (1 - $i)*duration/$n, which gives us the following Sass loop:

@for $i from 2 to ($n + 1) {
  .gallery > img:nth-child(#{$i}) {
    animation-delay: calc(#{(1 - $i) / $n} * 8s);
  }
}

We can make the duration a variable as well if we really want to. But let’s move on to the animation:

@keyframes m {
  0%, 3% { transform: rotate(0); }
  22%, 27% { transform: rotate(-90deg); }
  47%, 52% { transform: rotate(-180deg); }
  72%, 77% { transform: rotate(-270deg); }
  98%, 100% {transform: rotate(-360deg); }
}

Let’s simplify it to get a better view of the pattern:

@keyframes m {
  0% { transform: rotate(0); }
  25% { transform: rotate(-90deg); }
  50% { transform: rotate(-180deg); }
  75% { transform: rotate(-270deg); }
  100% { transform: rotate(-360deg); }
}

The step between each state is equal to 25% — which is 100%/4 — and we add a -90deg angle — which is -360deg/4. That means we can write our loop like this instead:

@keyframes m {
  0% { transform: rotate(0); }
  @for $i from 1 to $n {
    #{($i / $n) * 100}% { transform: rotate(#{($i / $n) * -360}deg); }  
  }
  100% { transform: rotate(-360deg); }
}

Since each image takes 5% of the animation, we change this:

#{($i / $n) * 100}%

…with this:

#{($i / $n) * 100 - 2}%, #{($i / $n) * 100 + 3}%

It should be noted that 5% is an arbitrary value I choose for this example. We can also make it a variable to control how much time each image should stay visible. I am going to skip that for the sake of simplicity, but for homework, you can try to do it and share your implementation in the comments!

@keyframes m {
  0%,3% { transform: rotate(0); }
  @for $i from 1 to $n {
    #{($i / $n) * 100 - 2}%, #{($i / $n) * 100 + 3}% { transform: rotate(#{($i / $n) * -360}deg); }  
  }
  98%,100% { transform: rotate(-360deg); }
}

The last bit is to update transform-origin. We will need some geometry tricks. Whatever the number of images, the configuration is always the same. We have our images (small circles) placed inside a big circle and we need to find the value of the radius, R.

You probably don’t want a boring geometry explanation so here’s how we find R:

R = S / (2 * sin(180deg / N))

If we express that as a percentage, that gives us:

R = 100% / (2 * sin(180deg / N)) = 50% / sin(180deg / N)

…which means the transform-origin value is equal to:

transform-origin: 50% (50% / math.sin(180deg / $n) + 50%);

We’re done! We have a slider that works with any number images!

Let’s toss nine images in there:

Add as many images as you want and update the $n variable with the total number of images.

Wrapping up

With a few tricks using CSS transforms and standard geometry, we created a nice circular slider that doesn’t require a lot of code. What is cool about this slider is that we don’t need to bother duplicating the images to keep the infinite animation since we have a circle. After a full rotation, we will get back to the first image!


CSS Infinite and Circular Rotating Image Slider originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/css-only-infinite-and-circular-image-slider/feed/ 7 375308
Building a Scrollable and Draggable Timeline with GSAP https://css-tricks.com/building-a-scrollable-and-draggable-timeline-with-gsap/ Wed, 02 Feb 2022 21:11:55 +0000 https://css-tricks.com/?p=362893 Here’s a super classy demo from Michelle Barker over on Codrops that shows how to build a scrollable and draggable timeline with GSAP. It’s an interesting challenge to have two different interactions (vertical scrolling and horizontal dragging) be tied together …


Building a Scrollable and Draggable Timeline with GSAP originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Here’s a super classy demo from Michelle Barker over on Codrops that shows how to build a scrollable and draggable timeline with GSAP. It’s an interesting challenge to have two different interactions (vertical scrolling and horizontal dragging) be tied together and react to each other. I love seeing it all done with nice semantic markup, code that’s easy to follow, clear abstractions, and accessibility considered all the way through.

To Shared LinkPermalink on CSS-Tricks


Building a Scrollable and Draggable Timeline with GSAP originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
362893
Creating WebGL Effects with CurtainsJS https://css-tricks.com/creating-webgl-effects-with-curtainsjs/ https://css-tricks.com/creating-webgl-effects-with-curtainsjs/#comments Wed, 18 Nov 2020 15:51:21 +0000 https://css-tricks.com/?p=325353 This article focuses adding WebGL effects to <image> and <video> elements of an already “completed” web page. While there are a few helpful resources out there on this subject (like these two), I hope to help simplify this subject …


Creating WebGL Effects with CurtainsJS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
This article focuses adding WebGL effects to <image> and <video> elements of an already “completed” web page. While there are a few helpful resources out there on this subject (like these two), I hope to help simplify this subject by distilling the process into a few steps: 

  • Create a web page as you normally would.
  • Render pieces that you want to add WebGL effects to with WebGL.
  • Create (or find) the WebGL effects to use.
  • Add event listeners to connect your page with the WebGL effects.

Specifically, we’ll focus on the connection between regular web pages and WebGL. What are we going to make? How about a draggle image slider with an interactive mouse hover!

We won’t cover the core functionality of slider or go very far into the technical details of WebGL or GLSL shaders. However, there are plenty of comments in the demo code and links to outside resources if you’d like to learn more. 

We’re using the latest version of WebGL (WebGL2) and GLSL (GLSL 300) which currently do not work in Safari or in Internet Explorer. So, use Firefox or Chrome to view the demos. If you’re planning to use any of what we’re covering in production, you should load both the GLSL 100 and 300 versions of the shaders and use the GLSL 300 version only if curtains.renderer._isWebGL2 is true. I cover this in the demo above.

First, create a web page as you normally would

You know, HTML and CSS and whatnot. In this case, we’re making an image slider but that’s just for demonstration. We’re not going to go full-depth on how to make a slider (Robin has a nice post on that). But here’s what I put together:

  1. Each slide is equal to the full width of the page.
  2. After a slide has been dragged, the slider continues to slide in the direction of the drag and gradually slow down with momentum.
  3. The momentum snaps the slider to the nearest slide at the end point. 
  4. Each slide has an exit animation that’s fired when the drag starts and an enter animation that’s fired when the dragging stops.
  5. When hovering the slider, a hover effect is applied similar to this video.

I’m a huge fan of the GreenSock Animation Platform (GSAP). It’s especially useful for us here because it provides a plugin for dragging, one that enables momentum on drag, and one for splitting text by line . If you’re uncomfortable creating sliders with GSAP, I recommend spending some time getting familiar with the code in the demo above.

Again, this is just for demonstration, but I wanted to at least describe the component a bit. These are the DOM elements that we will keep our WebGL synced with. 

Next, use WebGL to render the pieces that will contain WebGL effects

Now we need to render our images in WebGL. To do that we need to:

  1. Load the image as a texture into a GLSL shader.
  2. Create a WebGL plane for the image and correctly apply the image texture to the plane.
  3. Position the plane where the DOM version of the image is and scale it correctly.

The third step is particularly non-trivial using pure WebGL because we need to track the position of the DOM elements we want to port into the WebGL world while keeping the DOM and WebGL parts in sync during scroll and user interactions.

There’s actually a library that helps us do all of this with ease: CurtainsJS! It’s the only library I’ve found that easily creates WebGL versions of DOM images and videos and syncs them without too many other features (but I’d love to be proven wrong on that point, so please leave a comment if you know of others that do this well).

With Curtains, this is all the JavaScript we need to add:

// Create a new curtains instance
const curtains = new Curtains({ container: "canvas", autoRender: false });
// Use a single rAF for both GSAP and Curtains
function renderScene() {
  curtains.render();
}
gsap.ticker.add(renderScene);
// Params passed to the curtains instance
const params = {
  vertexShaderID: "slider-planes-vs", // The vertex shader we want to use
  fragmentShaderID: "slider-planes-fs", // The fragment shader we want to use
  
 // Include any variables to update the WebGL state here
  uniforms: {
    // ...
  }
};
// Create a curtains plane for each slide
const planeElements = document.querySelectorAll(".slide");
planeElements.forEach((planeEl, i) => {
  const plane = curtains.addPlane(planeEl, params);
  // const plane = new Plane(curtains, planeEl, params); // v7 version
  // If our plane has been successfully created
  if(plane) {
    // onReady is called once our plane is ready and all its texture have been created
    plane.onReady(function() {
      // Add a "loaded" class to display the image container
      plane.htmlElement.closest(".slide").classList.add("loaded");
    });
  }
});

We also need to update our updateProgress function so that it updates our WebGL planes.

function updateProgress() {
  // Update the actual slider
  animation.progress(wrapVal(this.x) / wrapWidth);
  
  // Update the WebGL slider planes
  planes.forEach(plane => plane.updatePosition());
}

We also need to add a very basic vertex and fragment shader to display the texture that we’re loading. We can do that by loading them via <script> tags, like I do in the demo, or by using backticks as I show in the final demo.  

Again, this article will not go into a lot of detail on the technical aspects of these GLSL shaders. I recommend reading The Book of Shaders and the WebGL topic on Codrops as starting points.

If you don’t know much about shaders, it’s sufficient to say that the vertex shader positions the planes and the fragment shader processes the texture’s pixels. There are also three variable prefixes that I want to point out:

  • ins are passed in from a data buffer. In vertex shaders, they come from the CPU (our program). In fragment shaders, they come from the vertex shader.
  • uniforms are passed in from the CPU (our program).
  • outs are outputs from our shaders. In vertex shaders, they are passed into our fragment shader. In fragment shaders, they are passed to the frame buffer (what is drawn to the screen).

Once we’ve added all of that to our project, we have the same exact thing before but our slider is now being displayed via WebGL! Neat.

CurtainsJS easily converts images and videos to WebGL. As far as adding WebGL effects to text, there are several different methods but perhaps the most common is to draw the text to a <canvas> and then use it as a texture in the shader (e.g. 1, 2). It’s possible to do most other HTML using html2canvas (or similar) and use that canvas as a texture in the shader; however, this is not very performant.

Create (or find) the WebGL effects to use

Now we can add WebGL effects since we have our slider rendering with WebGL. Let’s break down the effects seen in our inspiration video:

  1. The image colors are inverted.
  2. There is a radius around the mouse position that shows the normal color and creates a fisheye effect.
  3. The radius around the mouse animates from 0 when the slider is hovered and animates back to 0 when it is no longer hovered.
  4. The radius doesn’t jump to the mouse’s position but animates there over time.
  5. The entire image translates based on the mouse’s position in reference to the center of the image.

When creating WebGL effects, it’s important to remember that shaders don’t have a memory state that exists between frames. It can do something based on where the mouse is at a given time, but it can’t do something based on where the mouse has been all by itself. That’s why for certain effects, like animating the radius once the mouse has entered the slider or animating the position of the radius over time, we should use a JavaScript variable and pass that value to each frame of the slider. We’ll talk more about that process in the next section.

Once we modify our shaders to invert the color outside of the radius and create the fisheye effect inside of the radius, we’ll get something like the demo below. Again, the point of this article is to focus on the connection between DOM elements and WebGL so I won’t go into detail about the shaders, but I did add comments to them.

But that’s not too exciting yet because the radius is not reacting to our mouse. That’s what we’ll cover in the next section.

I haven’t found a repository with a lot of pre-made WebGL shaders to use for regular websites. There’s ShaderToy and VertexShaderArt (which have some truly amazing shaders!), but neither is aimed at the type of effects that fit on most websites. I’d really like to see someone create a repository of WebGL shaders as a resource for people working on everyday sites. If you know of one, please let me know.

Add event listeners to connect your page with the WebGL effects

Now we can add interactivity to the WebGL portion! We need to pass in some variables (uniforms) to our shaders and affect those variables when the user interacts with our elements. This is the section where I’ll go into the most detail because it’s the core for how we connect JavaScript to our shaders.

First, we need to declare some uniforms in our shaders. We only need the mouse position in our vertex shader:

// The un-transformed mouse position
uniform vec2 uMouse;

We need to declare the radius and resolution in our fragment shader:

uniform float uRadius; // Radius of pixels to warp/invert
uniform vec2 uResolution; // Used in anti-aliasing

Then let’s add some values for these inside of the parameters we pass into our Curtains instance. We were already doing this for uResolution! We need to specify the name of the variable in the shader, it’s type, and then the starting value:

const params = {
  vertexShaderID: "slider-planes-vs", // The vertex shader we want to use
  fragmentShaderID: "slider-planes-fs", // The fragment shader we want to use
  
  // The variables that we're going to be animating to update our WebGL state
  uniforms: {
    // For the cursor effects
    mouse: { 
      name: "uMouse", // The shader variable name
      type: "2f",     // The type for the variable - https://webglfundamentals.org/webgl/lessons/webgl-shaders-and-glsl.html
      value: mouse    // The initial value to use
    },
    radius: { 
      name: "uRadius",
      type: "1f",
      value: radius.val
    },
    
    // For the antialiasing
    resolution: { 
      name: "uResolution",
      type: "2f", 
      value: [innerWidth, innerHeight] 
    }
  },
};

Now the shader uniforms are connected to our JavaScript! At this point, we need to create some event listeners and animations to affect the values that we’re passing into the shaders. First, let’s set up the animation for the radius and the function to update the value we pass into our shader:

const radius = { val: 0.1 };
const radiusAnim = gsap.from(radius, { 
  val: 0, 
  duration: 0.3, 
  paused: true,
  onUpdate: updateRadius
});
function updateRadius() {
  planes.forEach((plane, i) => {
    plane.uniforms.radius.value = radius.val;
  });
}

If we play the radius animation, then our shader will use the new value each tick.

We also need to update the mouse position when it’s over our slider for both mouse devices and touch screens. There’s a lot of code here, but you can walk through it pretty linearly. Take your time and process what’s happening.

const mouse = new Vec2(0, 0);
function addMouseListeners() {
  if ("ontouchstart" in window) {
    wrapper.addEventListener("touchstart", updateMouse, false);
    wrapper.addEventListener("touchmove", updateMouse, false);
    wrapper.addEventListener("blur", mouseOut, false);
  } else {
    wrapper.addEventListener("mousemove", updateMouse, false);
    wrapper.addEventListener("mouseleave", mouseOut, false);
  }
}


// Update the stored mouse position along with WebGL "mouse"
function updateMouse(e) {
  radiusAnim.play();
  
  if (e.changedTouches && e.changedTouches.length) {
    e.x = e.changedTouches[0].pageX;
    e.y = e.changedTouches[0].pageY;
  }
  if (e.x === undefined) {
    e.x = e.pageX;
    e.y = e.pageY;
  }
  
  mouse.x = e.x;
  mouse.y = e.y;
  
  updateWebGLMouse();
}


// Updates the mouse position for all planes
function updateWebGLMouse(dur) {
  // update the planes mouse position uniforms
  planes.forEach((plane, i) => {
    const webglMousePos = plane.mouseToPlaneCoords(mouse);
    updatePlaneMouse(plane, webglMousePos, dur);
  });
}


// Updates the mouse position for the given plane
function updatePlaneMouse(plane, endPos = new Vec2(0, 0), dur = 0.1) {
  gsap.to(plane.uniforms.mouse.value, {
    x: endPos.x,
    y: endPos.y,
    duration: dur,
    overwrite: true,
  });
}


// When the mouse leaves the slider, animate the WebGL "mouse" to the center of slider
function mouseOut(e) {
  planes.forEach((plane, i) => updatePlaneMouse(plane, new Vec2(0, 0), 1) );
  
  radiusAnim.reverse();
}

We should also modify our existing updateProgress function to keep our WebGL mouse synced.

// Update the slider along with the necessary WebGL variables
function updateProgress() {
  // Update the actual slider
  animation.progress(wrapVal(this.x) / wrapWidth);
  
  // Update the WebGL slider planes
  planes.forEach(plane => plane.updatePosition());
  
  // Update the WebGL "mouse"
  updateWebGLMouse(0);
}

Now we’re cooking with fire! Our slider now mets all of our requirements.

Two additional benefits of using GSAP for your animations is that it provides access to callbacks, like onComplete, and GSAP keeps everything perfectly synced no matter the refresh rate (e.g. this situation).

You take it from here!

This is, of course, just the tip of the iceberg when it comes to what we can do with the slider now that it is in WebGL. For example,  common effects like turbulence and displacement can be added to the images in WebGL. The core concept of a displacement effect is to move pixels around based on a gradient lightmap that we use as an input source. We can use this texture (that I pulled from this displacement demo by Jesper Landberg — you should give him a follow) as our source and then plug it into our shader. 

To learn more about creating textures like these, see this article, this tweet, and this tool. I am not aware of any existing repositories of images like these, but if you know of one please, let me know.

If we hook up the texture above and animate the displacement power and intensity so that they vary over time and based on our drag velocity, then it will create a nice semi-random, but natural-looking displacement effect:

It’s also worth noting that Curtains has its own React version if that’s how you like to roll.

That’s all I’ve got for now. If you create something using what you’ve learned from this article, I’d love to see it! Connect with me via Twitter.


Creating WebGL Effects with CurtainsJS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/creating-webgl-effects-with-curtainsjs/feed/ 4 325353
Let’s Make a Multi-Thumb Slider That Calculates The Width Between Thumbs https://css-tricks.com/lets-make-a-multi-thumb-slider-that-calculates-the-width-between-thumbs/ https://css-tricks.com/lets-make-a-multi-thumb-slider-that-calculates-the-width-between-thumbs/#comments Tue, 23 Jun 2020 14:54:56 +0000 https://css-tricks.com/?p=312728 HTML has an <input type="range">, which is, you could argue, the simplest type of proportion slider. Wherever the thumb of that slider ends up could represent a proportion of whatever is before and whatever is after it (using the …


Let’s Make a Multi-Thumb Slider That Calculates The Width Between Thumbs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
HTML has an <input type="range">, which is, you could argue, the simplest type of proportion slider. Wherever the thumb of that slider ends up could represent a proportion of whatever is before and whatever is after it (using the value and max attributes). Getting fancier, it’s possible to build a multi-thumb slider. But we’ve got another thing in mind today… a proportion slider with multiple thumbs, and sections that cannot overlap.

Here’s what we’ll be building today:

This is a slider, but not just any slider. It’s a proportion slider. Its various sections must all add up to 100% and the user can adjust the different sections in it.

Why would you need such a thing ?

Maybe you want to build a budget app where a slider like this would consist of your various planned expenses:

I needed something like this to make an anime movie suggestions platform. While researching UX patterns, I noticed that other anime movie suggestion websites have this sort of thing where you select tags to get movie recommendations. That’s cool and all, but how much cooler would it be to add weight to those tags so that recommendations for a tag with a larger weight are prioritized over other tags with lighter weights. I looked around for other ideas, but didn’t find much.

So I built this! And believe it or not, making it is not that complicated. Let me take you through the steps.

The static slider

We’ll be building this in React and TypeScript. That’s not required though. The concepts should port to vanilla JavaScript or any other framework.

Let’s make this slider using dynamic data for the start. We’ll keep the different sections in a variable array with the name and color of each section. 

const _tags = [
  {
    name: "Action",
    color: "red"
  },
  {
    name: "Romance",
    color: "purple"
  },
  {
    name: "Comedy",
    color: "orange"
  },
  {
    name: "Horror",
    color: "black"
  }
];

The width of each tag section is controlled by an array of percentages that add up to 100%. This is done by using the Array.Fill() method to initialize the state of each width:

const [widths, setWidths] = useState<number[]>(new Array(_tags.length).fill(100 / _tags.length))

Next, we’ll create a component to render a single tag section:

interface TagSectionProps {
  name: string
  color: string
  width: number
}


const TagSection = ({ name, color, width }: TagSectionProps) => {
  return <div
    className='tag'
    style={{ ...styles.tag, background: color, width: width + '%' }}
>
    <span style={styles.tagText}>{name}</span>
   <div
     style={styles.sliderButton}
     className='slider-button'>        
     <img src={"https://assets.codepen.io/576444/slider-arrows.svg"} height={'30%'} />
    </div>
  </div >
}

Then we’ll render all of the sections by mapping through the _tags array and return the TagSection component we created above:

const TagSlider = () => {
  const [widths, setWidths] = useState<number[]>((new Array(_tags.length).fill(100 / _tags.length)))
  return <div
    style={{
      width: '100%',
      display: 'flex'
    }}>
    {
    _tags.map((tag, index) => <TagSection
      width={widths[index]}
      key={index}
      name={tag.name}
      color={tag.color}
    />)
    }
  </div>
}

To make rounded borders and hide the last slider button, let’s the :first-of-type and :last-of-type pseudo-selectors in CSS:

.tag:first-of-type {
  border-radius: 50px 0px 0px 50px;
}
.tag:last-of-type {
  border-radius: 0px 50px 50px 0px;
}
.tag:last-of-type>.slider-button {
  display:none !important;
}

Here’s where we are so far. Note the slider handles don’t do anything yet! We’ll get to that next.

Adjustable slider sections

We want the slider sections to adjust move when the slider buttons are dragged with either a mouse cursor or touch, We’ll do that by making sure the section width changes correspond to how much the slider buttons have been dragged. That requires us to answer a few questions:

  1. How do we get the cursor position when the slider button is clicked?
  2. How do we get the cursor position while the slider button is being dragged?
  3. How can we make the width of the tag section correspond to how much the tag section button has been dragged ?

One by one…

How do we get the cursor position when the slider button is clicked?

Let’s add an onSliderSelect event handler to the TagSectionProps interface:

interface TagSectionProps {
  name: string;
  color: string;
  width: number;
  onSliderSelect: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}

The onSliderSelect event handler is attached to the onPointerDown event of the TagSection component:

const TagSection = ({
  name,
  color,
  width,
  onSliderSelect // Highlight
}: TagSectionProps) => {
  return (
    <div
      className="tag"
      style={{
        ...styles.tag,
        background: color,
        width: width + "%"
        }}
    >
      <span style={styles.tagText}>{name}</span>
      <span style={{ ...styles.tagText, fontSize: 12 }}>{width + "%"}</span>
  
      <div
        style={styles.sliderButton}
        onPointerDown={onSliderSelect}
        className="slider-button"
      >
      <img src={"https://animesonar.com/slider-arrows.svg"} height={"30%"} />
      </div>
    </div>
  );
};

We use onPointerDown instead of onMouseDown to catch both mouse and touch screen events. Here’s more information about that. 

We’re using e.pageX in the onSliderSelect prop function to get the cursor’s position when the slider’s button has been clicked:

<TagSection
  width={widths[index]}
  key={index}
  name={tag.name}
  onSliderSelect={(e) => {
    const startDragX = e.pageX;
  }}
/>

One down!

How do we get the cursor position while the slider button is being dragged?

Now we need to add an event listener to listen for the drag events, pointermove and touchmove. We’ll use these events to cover mouse cursor and touch movements. The section widths need to stop updating once the user’s finger raises from the screen (thus ending the drag): 

window.addEventListener("pointermove", resize);
window.addEventListener("touchmove", resize);


const removeEventListener = () => {
  window.removeEventListener("pointermove", resize);
  window.removeEventListener("touchmove", resize);
}


const handleEventUp = (e: Event) => {
  e.preventDefault();
  document.body.style.cursor = "initial";
  removeEventListener();
}


window.addEventListener("touchend", handleEventUp);
window.addEventListener("pointerup", handleEventUp);

The resize function gives the X coordinate of the cursor while the slider button is being dragged:

const resize = (e: MouseEvent & TouchEvent) => {
  e.preventDefault();
  const endDragX = e.touches ? e.touches[0].pageX : e.pageX
}

When the resize function is triggered by a touch event, e.touches is an array value — otherwise, it’s null, in which case endDragX takes the value of e.pageX

How can we make the width of the tag section correspond to how much the tag section button has been dragged?

To change the width percentages of the various tag sections, let’s get the distance the cursor moves in relation to the entire width of the slider as a percentage. From there, we’ll assign that value to the tag section.

First, we need to get the refof TagSlider using React’s useRef hook:

const TagSlider = () => {
const TagSliderRef = useRef<HTMLDivElement>(null);
  // TagSlider
  return (
    <div
      ref={TagSliderRef}
// ...

Now let’s figure out the width of the slider by using its reference to get the offsetWidth property, which returns the layout width of an element as an integer:

onSliderSelect={(e) => {
  e.preventDefault();
  document.body.style.cursor = 'ew-resize';


  const startDragX = e.pageX;
  const sliderWidth = TagSliderRef.current.offsetWidth;
}};

Then we calculate the percentage distance the cursor moved relative to the entire slider:

const getPercentage = (containerWidth: number, distanceMoved: number) => {
  return (distanceMoved / containerWidth) * 100;
};


const resize = (e: MouseEvent & TouchEvent) => {
  e.preventDefault();
  const endDragX = e.touches ? e.touches[0].pageX : e.pageX;
  const distanceMoved = endDragX - startDragX;
  const percentageMoved = getPercentage(sliderWidth, distanceMoved);
}

Finally, we can assign the newly calculated section width to its index on the _widths state variable:

const percentageMoved = getPercentage(sliderWidth, distanceMoved);
const _widths = widths.slice();
const prevPercentage = _widths[index];
const newPercentage = prevPercentage + percentageMoved


_widths[index] = newPercentage;
setWidths(_widths);

But this isn’t the end! The other sections aren’t changing widths and the percentages can wind up being negative or adding up to more than 100%. Not to mention, the sum of all section widths isn’t always equal to 100% because we haven’t applied a restriction that prevents the overall percentage from changing.

Fixing up the other sections

Let’s make sure the width of one section changes when the section next to it changes.

const nextSectionNewPercentage = percentageMoved < 0 
  ? _widths[nextSectionIndex] + Math.abs(percentageMoved)
  : _widths[nextSectionIndex] - Math.abs(percentageMoved)

This has the effect of reducing the width of the neighboring section if the section increases and vice-versa. We can even shorten it:

const nextSectionNewPercentage = _widths[nextSectionIndex] - percentageMoved

Adjusting a section percentage should only affect its neighbor to the right. This means that the maximum value of a given section maximum percentage should be its width plus the width of its neighbor width when it’s allowed to take up the entire neighbor’s space.

We can make that happen by calculating a maximum percentage value:

const maxPercent = widths[index] + widths[index+1]

To prevent negative width values, let’s restrict the widths to values greater than zero but less than the max percentage: 

const limitNumberWithinRange = (value: number, min: number, max: number):number => {
  return Math.min(Math.max(value,min),max)
}

The limitNumberWithinRange function both prevents negative values and instances where the sum of sections results ina value higher than the maximum percentage. (Hat tip to this StavkOverflow thread.)

We can use this function for the width of the current section and its neighbor:

const currentSectionWidth = limitNumberWithinRange(newPercentage, 0, maxPercent)
_widths[index] = currentSectionWidth


const nextSectionWidth = limitNumberWithinRange(nextSectionNewPercentage, 0, maxPercent);
_widths[nextSectionIndex] = nextSectionWidth;

Extra touches

Right now, the slider calculates the width of each section as a percentage of the entire container to some crazy decimal. That’s super precise, but not exactly useful for this sort of UI. If we want to work with whole numbers instead of decimals, we can do something like this:

const nearestN = (N: number, number: number) => Math.ceil(number / N) * N;
const percentageMoved = nearestN(1, getPercentage(sliderWidth, distanceMoved))

This function approximates the second parameter’s value to the nearest N (specified by the first parameter). Setting  N to 1 like the this example has the effect of making the percentage change in whole numbers instead of tiny incremental decimals.

Another nice to touch is consider additional handling for sections with a zero percentage value. Those should probably be removed from the slider altogether since they no longer take up any proportion of the overall width. We can stop listening for events on those sections:

if (tags.length > 2) {
  if (_widths[index] === 0) {
     _widths[nextSectionIndex] = maxPercent;
    _widths.splice(index, 1);
    setTags(tags.filter((t, i) => i !== index));
    removeEventListener();
  }
  if (_widths[nextSectionIndex] === 0) {
    _widths[index] = maxPercent;
    _widths.splice(nextSectionIndex, 1);
    setTags(tags.filter((t, i) => i !== nextSectionIndex));
    removeEventListener();
  }
}

Voila!

Here’s the final slider:


Let’s Make a Multi-Thumb Slider That Calculates The Width Between Thumbs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/lets-make-a-multi-thumb-slider-that-calculates-the-width-between-thumbs/feed/ 7 312728
CSS-Only Carousel https://css-tricks.com/css-only-carousel/ https://css-tricks.com/css-only-carousel/#comments Fri, 10 Jan 2020 16:06:41 +0000 https://css-tricks.com/?p=300964 It’s kind of amazing how far HTML and CSS will take you when building a carousel/slideshow.

  1. Setting some boxes in a horizontal row with CSS Flexbox is easy.
  2. Showing only one box at a time with overflow and making it


CSS-Only Carousel originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
It’s kind of amazing how far HTML and CSS will take you when building a carousel/slideshow.

  1. Setting some boxes in a horizontal row with CSS Flexbox is easy.
  2. Showing only one box at a time with overflow and making it swipable with overscroll-behavior is easy.
  3. You can make the “slides” line up nicely with scroll-snap-type.
  4. A couple of #jump-links is all you need to make navigation for it, which you can make all nice and smooth with scroll-behavior.

Christian Schaefer has taken it a little further with next and previous buttons, plus an auto-play feature that stops playing once interaction starts.

About that auto-play thing — it’s a bonafide CSS trick:

  1. First I slowly offset the scroll snap points to the right, making the scroll area follow along due to being snapped to them.
  2. After having scrolled the width of a whole slide, I deactivate the snapping. The scroll area is now untied from the scroll snap points.
  3. Now I let the scroll snap points jump back to their initial positions without them “snap-dragging” the scroll area back with them
  4. Then I re-engage the snapping which now lets the scroll area snap to a different snap point 🤯

Cool.

JavaScript-powered slideshows (e.g. with Flickty) can be real nice, too. There is just something neat about getting it done with so little code.

And speaking of how far we can get with HTML and CSS, here are a few related takes on image carousels and galleries.


CSS-Only Carousel originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/css-only-carousel/feed/ 20 300964
Multi-Thumb Sliders: General Case https://css-tricks.com/multi-thumb-sliders-general-case/ https://css-tricks.com/multi-thumb-sliders-general-case/#comments Wed, 08 Jan 2020 16:11:49 +0000 https://css-tricks.com/?p=300428

The first part of this two-part series detailed how we can get a two-thumb slider. Now we’ll look at a general multi-thumb case, but with a different and better technique for creating the fills in between the thumbs. And finally, …


Multi-Thumb Sliders: General Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>

The first part of this two-part series detailed how we can get a two-thumb slider. Now we’ll look at a general multi-thumb case, but with a different and better technique for creating the fills in between the thumbs. And finally, we’ll dive into the how behind the styling a realistic 3D-looking slider and a flat one.

Article Series:

  1. Multi-Thumb Sliders: Particular Two-Thumb Case
  2. Multi-Thumb Sliders: General Case (This Post)

A better, more flexible approach

Let’s say that, on a wrapper pseudo-element that covers the same area as the range inputs, we stack left-to–right linear-gradient() layers corresponding to each thumb. Each gradient layer is fully opaque (i.e. the alpha is 1) from the track minimum up to the thumb’s mid-line, after which it’s fully transparent (i.e. the alpha is 0).

Note that the RGB values don’t matter because all we care about are the alpha values. I personally use the red (for the fully opaque part) and transparent keywords in the code because they do the job with the least amount of characters.

How do we compute the gradient stop positions where we go from fully opaque to fully transparent? Well, these positions are always situated between a thumb radius from the left edge and a thumb radius from the right edge, so they are within a range that’s equal to the useful width (the track width, minus the thumb diameter).

This means we first add a thumb radius.Then we compute the progress by dividing the difference between the current thumb’s position and the minimum to the difference (--dif) between the maximum and the minimum. This progress value is a number in the [0, 1] interval — that’s 0 when the current thumb position is at the slider’s minimum, and 1 when the current thumb position is at the slider’s maximum. To get where exactly along that useful width interval we are, we multiply this progress value with the useful width.

The position we’re after is the sum between these two length values: the thumb radius and how far we are across the useful width interval.

The demo below allows us to see how everything looks stacked up in the 2D view and how exactly the range inputs and the gradients on their parent’s pseudo-element get layered in the 3D view. It’s also interactive, so we can drag the slider thumbs and see how the corresponding fill (which is created by a gradient layer on its parent’s pseudo-element) changes.

See the Pen by thebabydino (@thebabydino) on CodePen.

The demo is best viewed in Chrome and Firefox.

Alright, but simply stacking these gradient layers doesn’t give us the result we’re after.

The solution here is to make these gradients mask layers and then XOR them (more precisely, in the case of CSS masks, this means to XOR their alphas).

If you need a refresher on how XOR works, here’s one: given two inputs, the output of this operation is 1 if the input values are different (one of them is 1 and the other one is 0) and 0 if the input values are identical (both of them are 0 or both of them are 1)

The truth table for the XOR operation looks as follows:

Inputs Output
A B
0 0 0
0 1 1
1 0 1
1 1 0

You can also play with it in the following interactive demo, where you can toggle the input values and see how the output changes:

See the Pen by thebabydino (@thebabydino) on CodePen.

In our case, the input values are the alphas of the gradient mask layers along the horizontal axis. XOR-ing multiple layers means doing so for the first two from the bottom, then XOR-ing the third from the bottom with the result of the previous XOR operation and so on. For our particular case of left-to-right gradients with an alpha equal to 1 up to a point (decided by the corresponding thumb value) and then 0, it looks as illustrated below (we start from the bottom and work our way up):

SVG illustration. Illustrates the process described in the following three paragraphs.
How we XOR the gradient layer alphas (Demo).

Where both layers from the bottom have an alpha of 1, the resulting layer we get after XOR-ing them has an alpha of 0. Where they have different alpha values, the resulting layer has an alpha of 1. Where they both have an alpha of 0, the resulting layer has an alpha of 0.

Moving up, we XOR the third layer with the resulting layer we got at the previous step. Where both these layers have the same alpha, the alpha of the layer that results from this second XOR operation is 0. Where they have different alphas, the resulting alpha is 1.

Similarly, we then XOR the fourth layer from the bottom with the layer resulting from the second stage XOR operation.

In terms of CSS, this means using the exclude value for the standard mask-composite and the xor value for the non-standard -webkit-mask-composite. (For a better understanding of mask compositing, check out the crash course.)

This technique gives us exactly the result we want while also allowing us to use a single pseudo-element for all the fills. It’s also a technique that works for any number of thumbs. Let’s see how we can put it into code!

In order to keep things fully flexible, we start by altering the Pug code such that it allows to add or remove a thumb and update everything else accordingly by simply adding or removing an item from an array of thumb objects, where every object contains a value and a label (which will be only for screen readers):

- let min = -50, max = 50;
- let thumbs = [
-   { val: -15, lbl: 'Value A' }, 
-   { val: 20, lbl: 'Value B' }, 
-   { val: -35, lbl: 'Value C' }, 
-   { val: 45, lbl: 'Value D' }
- ];
- let nv = thumbs.length;

.wrap(role='group' aria-labelledby='multi-lbl' 
      style=`${thumbs.map((c, i) => `--v${i}: ${c.val}`).join('; ')}; 
             --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
    - for(let i = 0; i < nv; i++)
      label.sr-only(for=`v${i}`) #{thumbs[i].lbl}
      input(type='range' id=`v${i}` min=min value=thumbs[i].val max=max)
      output(for=`v${i}` style=`--c: var(--v${i})`)

In the particular case of these exact four values, the generated markup looks as follows:

<div class='wrap' role='group' aria-labelledby='multi-lbl' 
     style='--v0: -15; --v1: 20; --v2: -35; --v3: 45; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='v0'>Value A</label>
  <input type='range' id='v0' min='-50' value='-15' max='50'/>
  <output for='v0' style='--c: var(--v0)'></output>
  <label class='sr-only' for='v1'>Value B</label>
  <input type='range' id='v1' min='-50' value='20' max='50'/>
  <output for='v1' style='--c: var(--v1)'></output>
  <label class='sr-only' for='v2'>Value C</label>
  <input type='range' id='v2' min='-50' value='-35' max='50'/>
  <output for='v2' style='--c: var(--v2)'></output>
  <label class='sr-only' for='v3'>Value D</label>
  <input type='range' id='v3' min='-50' value='45' max='50'/>
  <output for='v3' style='--c: var(--v3)'></output>
</div>

We don’t need to add anything to the CSS or the JavaScript for this to give us a functional slider where the <output> values get updated as we drag the sliders. However, having four <output> elements while the wrapper’s grid still has two columns would break the layout. So, for now, we remove the row introduced for the <output> elements, position these elements absolutely and only make them visible when the corresponding <input> is focused. We also remove the remains of the previous solution that uses both pseudo-elements on the wrapper.

.wrap {
  /* same as before */
  grid-template-rows: max-content #{$h}; /* only 2 rows now */

  &::after {
    background: #95a;
    // content: ''; // don't display for now
    grid-column: 1/ span 2;
    grid-row: 3;
  }
}

input[type='range'] {
  /* same as before */
  grid-row: 2; /* last row is second row now */
}

output {
  color: transparent;
  position: absolute;
  right: 0;
	
  &::after {
    content: counter(c);
    counter-reset: c var(--c);
  }
}

We’ll be doing more to prettify the result later, but for now, here’s what we have:

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we need to get those thumb to thumb fills. We do this by generating the mask layers in the Pug and putting them in a --fill custom property on the wrapper.

//- same as before
- let layers = thumbs.map((c, i) => `linear-gradient(90deg, red calc(var(--r) + (var(--v${i}) - var(--min))/var(--dif)*var(--uw)), transparent 0)`);

.wrap(role='group' aria-labelledby='multi-lbl' 
  style=`${thumbs.map((c, i) => `--v${i}: ${c.val}`).join('; ')}; 
    --min: ${min}; --max: ${max};
    --fill: ${layers.join(', ')}`)
  // - same as before

The generated HTML for the particular case of four thumbs with these values can be seen below. Note that this gets altered automatically if we add or remove items from the initial array:

<div class='wrap' role='group' aria-labelledby='multi-lbl' 
  style='--v0: -15; --v1: 20; --v2: -35; --v3: 45; 
    --min: -50; --max: 50;
    --fill: 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v0) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v1) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v2) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v3) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0)'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='v0'>Value A</label>
  <input type='range' id='v0' min='-50' value='-15' max='50'/>
  <output for='v0' style='--c: var(--v0)'></output>
  <label class='sr-only' for='v1'>Value B</label>
  <input type='range' id='v1' min='-50' value='20' max='50'/>
  <output for='v1' style='--c: var(--v1)'></output>
  <label class='sr-only' for='v2'>Value C</label>
  <input type='range' id='v2' min='-50' value='-35' max='50'/>
  <output for='v2' style='--c: var(--v2)'></output>
  <label class='sr-only' for='v3'>Value D</label>
  <input type='range' id='v3' min='-50' value='45' max='50'/>
  <output for='v3' style='--c: var(--v3)'></output>
</div>

Note that this means we need to turn the Sass variables relating to dimensions into CSS variables and replace the Sass variables in the properties that use them:

.wrap {
  /* same as before */
  --w: 20em;
  --h: 4em;
  --d: calc(.5*var(--h));
  --r: calc(.5*var(--d));
  --uw: calc(var(--w) - var(--d));
  background: linear-gradient(0deg, #ccc var(--h), transparent 0);
  grid-template: max-content var(--h)/ var(--w);
  width: var(--w);
}

We set our mask Oo the wrapper’s ::after pseudo-element:

.wrap {
  /* same as before */
  
  &::after {
    content: '';
    background: #95a;
    grid-column: 1/ span 2;
    grid-row: 2;

    /* non-standard WebKit version */
    -webkit-mask: var(--fill);
    -webkit-mask-composite: xor;

    /* standard version, supported in Firefox */
    mask: var(--fill);
    mask-composite: exclude;
  }
}

Now we have exactly what we want and the really cool thing about this technique is that all we need to do to change the number of thumbs is add or remove thumb objects (with a value and a label for each) to the thumbs array in the Pug code — absolutely nothing else needs to change!

See the Pen by thebabydino (@thebabydino) on CodePen.

Prettifying tweaks

What we have so far is anything but a pretty sight. So let’s start fixing that!

Option #1: a realistic look

Let’s say we want to achieve the result below:

Screenshot. The track and fill are the same height as the thumbs. The track looks carved into the page, while the fill and the thumb have a convex look inside it.
The realistic look we’re after.

A first step would be to make the track the same height as the thumb and round the track ends. Up to this point, we’ve emulated the track with a background on the .wrap element. While it’s technically possible to emulate a track with rounded ends by using layered linear and radial gradients, it’s really not the best solution, especially when the wrapper still has a free pseudo-element (the ::before).

.wrap {
  /* same as before */
  --h: 2em;
  --d: var(--h);
  
  &::before, &::after {
    border-radius: var(--r);
    background: #ccc;
    content: '';
    grid-column: 1/ span 2;
    grid-row: 2;
  }
  
  &::after {
    background: #95a;

    /* non-standard WebKit version */
    -webkit-mask: var(--fill);
    -webkit-mask-composite: xor;

    /* standard version, supported in Firefox */
    mask: var(--fill);
    mask-composite: exclude;
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Using ::before to emulate the track opens up the possibility of getting a slightly 3D look:

<pre rel="SCSS"><code class="language-scss">.wrap {
  /* same as before */
  
  &::before, &::after {
    /* same as before */
    box-shadow: inset 0 2px 3px rgba(#000, .3);
  }
  
  &::after {
    /* same as before */
    background: 
      linear-gradient(rgba(#fff, .3), rgba(#000, .3))
      #95a;
  }
}

I’m by no means a designer, so those values could probably be tweaked for a better looking result, but we can already see a difference:

See the Pen by thebabydino (@thebabydino) on CodePen.

This leaves us with a really ugly thumb, so let’s fix that part as well!

We make use of the technique of layering multiple backgrounds with different background-clip (and background-origin) values.

@mixin thumb() {
  border: solid calc(.5*var(--r)) transparent;
  border-radius: 50%; /* make circular */
  box-sizing: border-box; /* different between Chrome & Firefox */
  /* box-sizing needed now that we have a non-zero border */
  background: 
    linear-gradient(rgba(#000, .15), rgba(#fff, .2)) content-box, 
    linear-gradient(rgba(#fff, .3), rgba(#000, .3)) border-box, 
    currentcolor;
  pointer-events: auto;
  width: var(--d); height: var(--d);
}

I’ve described this technique in a lot of detail in an older article. Make sure you check it out if you need a refresher!

The above bit of code would do close to nothing, however, if the currentcolor value is black (#000) which it is right now. Let’s fix that and also change the cursor on the thumbs to something more fitting:

input[type='range'] {
  /* same as before */
  color: #eee;
  cursor: grab;
  
  &:active { cursor: grabbing; }
}

The result is certainly more satisfying than before:

See the Pen by thebabydino (@thebabydino) on CodePen.

Something else that really bothers me is how close the label text is to the slider. We can fix this by introducing a grid-gap on the wrapper:

.wrap {
  /* same as before */
  grid-gap: .625em;
}

But the worst problem we still have are those absolutely positioned outputs in the top right corner. The best way to fix this is to introduce a third grid row for them and move them with the thumbs.

The position of the thumbs is computed in a similar manner to that of the sharp stops of the gradient layers we use for the fill mask.

Initially, we place the left edge of the outputs along the vertical line that’s a thumb radius --r away from the left edge of the slider. In order to middle align the outputs with this vertical line, we translate them back (to the left, in the negative direction of the x-axis, so we need a minus sign) by half of their width (50%, as percentage values in translate() functions are relative to the dimensions of the element the transform is applied to).

In order to move them with the thumbs, we subtract the minimum value (--min) from the current value of the corresponding thumb (--c), divide this difference by the difference (--dif) between the maximum value (--max) and the minimum value (--min). This gives us a progress value in the [0, 1] interval. We then multiply this value with the useful width (--uw), which describes the real range of motion.

.wrap {
  /* same as before */
  grid-template-rows: max-content var(--h) max-content;
}

output {
  background: currentcolor;
  border-radius: 5px;
  color: transparent;
  grid-column: 1;
  grid-row: 3;
  margin-left: var(--r);
  padding: 0 .375em;
  transform: translate(calc((var(--c) - var(--min))/var(--dif)*var(--uw) - 50%));
  width: max-content;
  
  &::after {
    color: #fff;
    content: counter(c);
    counter-reset: c var(--c);
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

This looks much better at a first glance. However, a closer inspection reveals that we still have a bunch of problems.

The first one is that overflow: hidden cuts out a bit of the <output> elements when we get to the track end.

In order to fix this, we must understand what exactly overflow: hidden does. It cuts out everything outside an element’s padding-box, as illustrated by the interactive demo below, where you can click the code to toggle the CSS declaration.

See the Pen by thebabydino (@thebabydino) on CodePen.

This means a quick fix for this issue is to add a big enough lateral padding on the wrapper .wrap.

padding: 0 2em;

We’re styling our multi-thumb slider in isolation here, but, in reality, it probably won’t be the only thing on a page, so, if spacing is limited, we can invert that lateral padding with a negative lateral margin.

If the nearby elements still have the default have position: static, the fact that we’ve relatively positioned the wrapper should make the outputs go on top of what they overlap, otherwise, tweaking the z-index on the .wrap should do it.

The bigger problem is that this technique we’ve used results in some really weird-looking <output> overlaps when were dragging the thumbs.

Increasing the z-index when the <input> is focused on the corresponding <output> as well solves the particular problem of the <output> overlaps:

input[type='range'] {
  &:focus {
    outline: solid 0 transparent;
		
    &, & + output {
      color: darkorange;
      z-index: 2;
    }
  }
}

However, it does nothing for the underlying issue and this becomes obvious when we change the background on the body, particularly if we change it to an image one, as this doesn’t allow the <output> text to hide in it anymore:

See the Pen by thebabydino (@thebabydino) on CodePen.

This means we need to rethink how we hide the <output> elements in the normal state and how we reveal them in a highlight state, such as :focus. We also want to do this without bloating our CSS.

The solution is to use the technique I described about a year ago in the “DRY Switching with CSS Variables” article: use a highlight --hl custom property where the value is 0 in the normal state and 1 in a highlight state (:focus). We also compute its negation (--nothl).

* {
  --hl: 0;
  --nothl: calc(1 - var(--hl));
  margin: 0;
  font: inherit
}

As it is, this does nothing yet. The trick is to make all properties that we want to change in between the two states depend on --hl and, if necessary, its negation (code>–nothl).

$hlc: #f90;

@mixin thumb() {
  /* same as before */
  background-color: $hlc;
}

input[type='range'] {
  /* same as before */
  filter: grayScale(var(--nothl));
  z-index: calc(1 + var(--hl));
  
  &:focus {
    outline: solid 0 transparent;
    
    &, & + output { --hl: 1; }
  }
}

output {
  /* same grid placement */
  margin-left: var(--r);
  max-width: max-content;
  transform: translate(calc((var(--c) - var(--min))/var(--dif)*var(--uw)));
	
  &::after {
    /* same as before */
    background: 
      linear-gradient(rgba(#fff, .3), rgba(#000, .3))
      $hlc;
    border-radius: 5px;
    display: block;
    padding: 0 .375em;
    transform: translate(-50%) scale(var(--hl));
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

We’re almost there! We can also add transitions on state change:

$t: .3s;

input[type='range'] {
  /* same as before */
  transition: filter $t ease-out;
}

output::after {
  /* same as before */
  transition: transform $t ease-out;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

A final improvement would be to grayscale() the fill if none of the thumbs are focused. We can do this by using :focus-within on our wrapper:

.wrap {
  &::after {
    /* same as before */
    filter: Grayscale(var(--nothl));
    transition: filter $t ease-out;
  }
	
  &:focus-within { --hl: 1; }
}

And that’s it!

See the Pen by thebabydino (@thebabydino) on CodePen.

Option #2: A flat look

Let’s see how we can get a flat design. For example:

Screenshot. All slider components are flat, no shadows or gradients. The track and fill are narrower than the thumbs and middle aligned with these. The track has a striped background. The thumbs are scaled down and reveal circular holes in the track around them in their unfocused state.
The flat look we’re after.

The first step is to remove the box shadows and gradients that give our previous demo a 3D look and make the track background a repeating gradient.:

See the Pen by thebabydino (@thebabydino) on CodePen.

The size change of the thumb on :focus can be controlled with a scaling transform with a factor that depends on the highlight switch variable (--hl).

@mixin thumb() {
  /* same as before */
  transform: scale(calc(1 - .5*var(--nothl)));
  transition: transform $t ease-out;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

But what about the holes in the track around the thumbs?

The mask compositing technique is extremely useful here. This involves layering radial gradients to create discs at every thumb position and, after we’re done with them, invert (i.e. compositing with a fully opaque layer) the result to turn those discs into holes.

SVG illustration. Shows that XOR-ing a bunch of radial-gradient() layers gives us a layer with opaque discs and everything else transparent and when, we xor this resulting layer with a fully opaque one, this fully opaque layer acts as an inverter, turning the discs into transparent holes in an otherwise fully opaque layer.
How we XOR the gradient layer alphas (Demo).

This means altering the Pug code a bit so that we’re generating the list of radial gradients that create the discs corresponding to each thumb. In turn, we’ll invert those in the CSS:

//- same as before
- let tpos = thumbs.map((c, i) => `calc(var(--r) + (var(--v${i}) - var(--min))/var(--dif)*var(--uw))`);
- let fill = tpos.map(c => `linear-gradient(90deg, red ${c}, transparent 0)`);
- let hole = tpos.map(c => `radial-gradient(circle at ${c}, red var(--r), transparent 0)`)

.wrap(role='group' aria-labelledby='multi-lbl' 
  style=`${thumbs.map((c, i) => `--v${i}: ${c.val}`).join('; ')}; 
    --min: ${min}; --max: ${max};
    --fill: ${fill.join(', ')}; 
    --hole: ${hole.join(', ')}`)
  // -same wrapper content as before

This generates the following markup:

<div class='wrap' role='group' aria-labelledby='multi-lbl' 
  style='--v0: -15; --v1: 20; --v2: -35; --v3: 45; 
    --min: -50; --max: 50;
    --fill: 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v0) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v1) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
       linear-gradient(90deg, 
         red calc(var(--r) + (var(--v2) - var(--min))/var(--dif)*var(--uw)), 
         transparent 0), 
       linear-gradient(90deg, 
         red calc(var(--r) + (var(--v3) - var(--min))/var(--dif)*var(--uw)), 
         transparent 0); 
     --hole: 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v0) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0), 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v1) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0), 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v2) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0), 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v3) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0)'>
  <!-- same content as before -->
</div>

In the CSS, we set a mask on both pseudo-elements and give a different value for each one. We also XOR the mask layers on them.

In the case of ::before, the mask is the list of radial-gradient() discs XOR-ed with a fully opaque layer (which acts as an inverter to turn the discs into circular holes). For ::after, it’s the list of fill linear-gradient() layers.

.wrap {
  /* same as before */
  
  &::before, &::after {
    content: '';
    /* same as before */
    
    --mask: linear-gradient(red, red), var(--hole);

    /* non-standard WebKit version */
    -webkit-mask: var(--mask);
    -webkit-mask-composite: xor;

    /* standard version, supported in Firefox */
    mask: var(--mask);
    mask-composite: exclude;
  }
	
  &::after {
    background: #95a;
    --mask: var(--fill);
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

The final step is to adjust the track, fill height, and middle align them vertically within their grid cell (along with the thumbs):

.wrap {
  /* same as before */
  
  &::before, &::after {
    /* same as before */
    align-self: center;
    height: 6px;
  }
}

We now have our desired flat multi-thumb slider!

See the Pen by thebabydino (@thebabydino) on CodePen.


Multi-Thumb Sliders: General Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/multi-thumb-sliders-general-case/feed/ 6 300428
Multi-Thumb Sliders: Particular Two-Thumb Case https://css-tricks.com/multi-thumb-sliders-particular-two-thumb-case/ Tue, 07 Jan 2020 15:51:44 +0000 https://css-tricks.com/?p=295854 This is a concept I first came across a few years back when Lea Verou wrote an article on it. Multi-range sliders have sadly been removed from the spec since, but something else that has happened in the meanwhile is …


Multi-Thumb Sliders: Particular Two-Thumb Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
This is a concept I first came across a few years back when Lea Verou wrote an article on it. Multi-range sliders have sadly been removed from the spec since, but something else that has happened in the meanwhile is that CSS got better — and so have I, so I recently decided to make my own 2019 version.

In this two-part article, we’ll go through the how, step-by-step, first building an example with two thumbs, then identify the issues with it. We’ll solve those issues, first for the two-thumb case then, in part two, come up with a better solution for the multi-thumb case.

Note how the thumbs can pass each other and we can have any possible order, with the fills in between the thumbs adapting accordingly. Surprisingly, the entire thing is going to require extremely little JavaScript.

Article Series:

  1. Multi-Thumb Sliders: Particular Two-Thumb Case (This Post)
  2. Multi-Thumb Sliders: General Case

Basic structure

We need two range inputs inside a wrapper. They both have the same minimum and maximum value (this is very important because nothing is going to work properly otherwise), which we set as custom properties on the wrapper (--min and --max). We also set their values as custom properties (--a and --b).

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  input#a(type='range' min=min value=a max=max)
  input#b(type='range' min=min value=b max=max)

This generates the following markup:

<div class='wrap' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <input id='b' type='range' min='-50' value='20' max='50'/>
</div>

Accessibility considerations

We have two range inputs and they should probably each have a <label>, but we want our multi-thumb slider to have a single label. How do we solve this issue? We can make the wrapper a <fieldset>, use its <legend> to describe the entire multi-thumb slider, and have a <label> that’s only visible to screen readers for each of our range inputs. (Thanks to Zoltan for this great suggestion.)

But what if we want to have a flex or grid layout on our wrapper? That’s something we probably want, as the only other option is absolute positioning and that comes with its own set of issues. Then we run into a Chromium issue where <fieldset> cannot be a flex or grid container.

To go around this, we use the following ARIA equivalent (which I picked up from this post by Steve Faulkner):

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
  label.sr-only(for='a') Value A:
  input#a(type='range' min=min value=a max=max)
  label.sr-only(for='b') Value B:
  input#b(type='range' min=min value=b max=max)

The generated markup is now:

<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='a'>Value A:</label>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <label class='sr-only' for='b'>Value B:</label>
  <input id='b' type='range' min='-50' value='20' max='50'/>
</div>

If we set an aria-label or an aria-labelledby attribute on an element, we also need to give it a role.

Basic styling

We make the wrapper a middle-aligned grid with two rows and one column. The bottom grid cell gets the dimensions we want for the slider, while the top one gets the same width as the slider, but can adjust its height according to the group label’s content.

$w: 20em;
$h: 1em;

.wrap {
  display: grid;
  grid-template-rows: max-content $h;
  margin: 1em auto;
  width: $w;
}

To visually hide the <label> elements, we absolutely position them and clip them to nothing:

.wrap {
  // same as before
  overflow: hidden; // in case <label> elements overflow
  position: relative;
}

.sr-only {
  position: absolute;
  clip-path: inset(50%);
}

Some people might shriek about clip-path support, like how using it cuts out pre-Chromium Edge and Internet Explorer, but it doesn’t matter in this particular case! We’re getting to the why behind that in a short bit.

We place the sliders, one on top of the other, in the bottom grid cell:

input[type='range'] {
  grid-column: 1;
  grid-row: 2;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

We can already notice a problem however: not only does the top slider track show up above the thumb of the bottom one, but the top slider makes it impossible for us to even click and interact with the bottom one using a mouse or touch.

In order to fix this, we remove any track backgrounds and borders and highlight the track area by setting a background on the wrapper instead. We also set pointer-events: none on the actual <input> elements and then revert to auto on their thumbs.

@mixin track() {
  background: none; /* get rid of Firefox track background */
  height: 100%;
  width: 100%;
}

@mixin thumb() {
  background: currentcolor;
  border: none; /* get rid of Firefox thumb border */
  border-radius: 0; /* get rid of Firefox corner rounding */
  pointer-events: auto; /* catch clicks */
  width: $h; height: $h;
}

.wrap {
  /* same as before */
  background: /* emulate track with wrapper background */ 
    linear-gradient(0deg, #ccc $h, transparent 0);
}

input[type='range'] {
  &::-webkit-slider-runnable-track, 
  &::-webkit-slider-thumb, & { -webkit-appearance: none; }
  
  /* same as before */
  background: none; /* get rid of white Chrome background */
  color: #000;
  font: inherit; /* fix too small font-size in both Chrome & Firefox */
  margin: 0;
  pointer-events: none; /* let clicks pass through */
  
  &::-webkit-slider-runnable-track { @include track; }
  &::-moz-range-track { @include track; }
  
  &::-webkit-slider-thumb { @include thumb; }
  &::-moz-range-thumb { @include thumb; }
}

Note that we’ve set a few more styles on the input itself as well as on the track and thumb in order to make the look consistent across the browsers that support letting clicks pass through the actual input elements and their tracks, while allowing them on the thumbs. This excludes pre-Chromium Edge and IE, which is why we haven’t included the -ms- prefix — there’s no point styling something that wouldn’t be functional in these browsers anyway. This is also why we can use clip-path to hide the <label> elements.

If you’d like to know more about default browser styles in order to understand what’s necessary to override here, you can check out this article where I take an in-depth look at range inputs (and where I also detail the reasoning behind using mixins here).

See the Pen by thebabydino (@thebabydino) on CodePen.

Alright, we now have something that looks functional. But in order to really make it functional, we need to move on to the JavaScript!

Functionality

The JavaScript is pretty straightforward. We need to update the custom properties we’ve set on the wrapper. (For an actual use case, they’d be set higher up in the DOM so that they’re also inherited by the elements whose styles that depend on them.)

addEventListener('input', e => {
  let _t = e.target;
  _t.parentNode.style.setProperty(`--${_t.id}`, +_t.value)
}, false);

See the Pen by thebabydino (@thebabydino) on CodePen.

However, unless we bring up DevTools to see that the values of those two custom properties really change in the style attribute of the wrapper .wrap, it’s not really obvious that this does anything. So let’s do something about that!

Showing values

Something we can do to make it obvious that dragging the thumbs actually changes something is to display the current values. In order to do this, we use an output element for each input:

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
  label.sr-only(for='a') Value A:
  input#a(type='range' min=min value=a max=max)
  output(for='a' style='--c: var(--a)')
  label.sr-only(for='b') Value B:
  input#b(type='range' min=min value=b max=max)
  output(for='b' style='--c: var(--b)')

The resulting HTML looks as follows:

<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='a'>Value A:</label>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <output for='a' style='--c: var(--a)'></output>
  <label class='sr-only' for='b'>Value B:</label>
  <input id='b' type='range' min='-50' value='20' max='50'/>
  <output for='b' style='--c: var(--b)'></output>
</div>

We display the values in an ::after pseudo-element using a little counter trick:

output {
  &::after {
    counter-reset: c var(--c);
    content: counter(c);
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

It’s now obvious these values change as we drag the sliders, but the result is ugly and it has messed up the wrapper background alignment, so let’s add a few tweaks! We could absolutely position the <output> elements, but for now, we simply squeeze them in a row between the group label and the sliders:

.wrap {
  // same as before
  grid-template: repeat(2, max-content) #{$h}/ 1fr 1fr;
}

[id='multi-lbl'] { grid-column: 1/ span 2 }

input[type='range'] {
  // same as before
  grid-column: 1/ span 2;
  grid-row: 3;
}

output {
  grid-row: 2;
  
  &:last-child { text-align: right; }
  
  &::after {
    content: '--' attr(for) ': ' counter(c) ';'
    counter-reset: c var(--c);
  }
}

Much better!

See the Pen by thebabydino (@thebabydino) on CodePen.

Setting separate :focus styles even gives us something that doesn’t look half bad, plus allows us to see which value we’re currently modifying.

input[type='range'] {
  /* same as before */
  z-index: 1;

  &:focus {
    z-index: 2;
    outline: dotted 1px currentcolor;
    
    &, & + output { color: darkorange }
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

All we need now is to create the fill between the thumbs.

The tricky part

We can recreate the fill with an ::after pseudo-element on the wrapper, which we place on the bottom grid row where we’ve also placed the range inputs. This pseudo-element comes, as the name suggests, after the inputs, but it will still show up underneath them because we’ve set positive z-index values on them. Note that setting the z-index works on the inputs (without explicitly setting their position to something different from static) because they’re grid children.

The width of this pseudo-element should be proportional to the difference between the higher input value and the lower input value. The big problem here is that they pass each other and we have no way of knowing which has the higher value.

First approach

My first idea on how to solve this was by using width and min-width together. In order to better understand how this works, consider that we have two percentage values, --a and --b, and we want to make an element’s width be the absolute value of the difference between them.

Either one of the two values can be the bigger one, so we pick an example where --b is bigger and an example where --a is bigger:

<div style='--a: 30%; --b: 50%'><!-- first example, --b is bigger --></div>
<div style='--a: 60%; --b: 10%'><!-- second example, --a is bigger --></div>

We set width to the second value (--b) minus the first (--a) and min-width to the first value (--a) minus the second one (--b).

div {
  background: #f90;
  height: 4em;
  min-width: calc(var(--a) - var(--b));
  width: calc(var(--b) - var(--a));
}

If the second value (--b) is bigger, then the width is positive (which makes it valid) and the min-width negative (which makes it invalid). That means the computed value is the one set via the width property. This is the case in the first example, where --b is 70% and --a is 50%. That means the width computes to 70% - 50% = 20%, while the min-width computes to 50% - 70% = -20%.

If the first value is bigger, then the width is negative (which makes it invalid) and the min-width is positive (which makes it valid), meaning the computed value is that set via the min-width property. This is the case in the second example, where --a is 80% and --b is 30%, meaning the width computes to 30% - 80% = -50%, while the min-width computes to 80% - 30% = 50%.

See the Pen by thebabydino (@thebabydino) on CodePen.

Applying this solution for our two thumb slider, we have:

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
  
  &::after {
    content: '';
    background: #95a;
    grid-column: 1/ span 2;
    grid-row: 3;
    min-width: calc((var(--a) - var(--b))/var(--dif)*100%);
    width: calc((var(--b) - var(--a))/var(--dif)*100%);
  }
}

In order to represent the width and min-width values as percentages, we need to divide the difference between our two values by the difference (--dif) between the maximum and the minimum of the range inputs and then multiply the result we get by 100%.

See the Pen by thebabydino (@thebabydino) on CodePen.

So far, so good… so what?

The ::after always has the right computed width, but we also need to offset it from the track minimum by the smaller value and we can’t use the same trick for its margin-left property.

My first instinct here was to use left, but actual offsets don’t work on their own. We’d have to also explicitly set position: relative on our ::after pseudo-element in order to make it work. I felt kind of meh about doing that, so I opted for margin-left instead.

The question is what approach can we take for this second property. The one we’ve used for the width doesn’t work because there is no such thing as min-margin-left.

A min() function is now in the CSS spec, but at the time when I coded these multi-thumb sliders, it was only implemented by Safari (it has since landed in Chrome as well). Safari-only support was not going to cut it for me since I don’t own any Apple device or know anyone in real life who does… so I couldn’t play with this function! And not being able to come up with a solution I could actually test meant having to change the approach.

Second approach

This involves using both of our wrapper’s (.wrap) pseudo-elements: one pseudo-element’s margin-left and width being set as if the second value is bigger, and the other’s set as if the first value is bigger.

With this technique, if the second value is bigger, the width we’re setting on ::before is positive and the one we’re setting on ::after is negative (which means it’s invalid and the default of 0 is applied, hiding this pseudo-element). Meanwhile, if the first value is bigger, then the width we’re setting on ::before is negative (so it’s this pseudo-element that has a computed width of 0 and is not being shown in this situation) and the one we’re setting on ::after is positive.

Similarly, we use the first value (--a) to set the margin-left property on the ::before since we assume the second value --b is bigger for this pseudo-element. That means --a is the value of the left end and --b the value of the right end.

For ::after, we use the second value (--b) to set the margin-left property, since we assume the first value --a is bigger this pseudo-element. That means --b is the value of the left end and --a the value of the right end.

Let’s see how we put it into code for the same two examples we previously had, where one has --b bigger and another where --a is bigger:

<div style='--a: 30%; --b: 50%'></div>
<div style='--a: 60%; --b: 10%'></div>
div {
  &::before, &::after {
    content: '';
    height: 5em;
  }
  
  &::before {
    margin-left: var(--a);
    width: calc(var(--b) - var(--a));
  }

  &::after {
    margin-left: var(--b);
    width: calc(var(--a) - var(--b));
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Applying this technique for our two thumb slider, we have:

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
  
  &::before, &::after {
    grid-column: 1/ span 2;
    grid-row: 3;
    height: 100%;
    background: #95a;
    content: ''
  }
  
  &::before {
    margin-left: calc((var(--a) - var(--min))/var(--dif)*100%);
    width: calc((var(--b) - var(--a))/var(--dif)*100%)
  }
  
  &::after {
    margin-left: calc((var(--b) - var(--min))/var(--dif)*100%);
    width: calc((var(--a) - var(--b))/var(--dif)*100%)
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

We now have a nice functional slider with two thumbs. But this solution is far from perfect.

Issues

The first issue is that we didn’t get those margin-left and width values quite right. It’s just not noticeable in this demo due to the thumb styling (such as its shape, dimensions relative to the track, and being full opaque).

But let’s say our thumb is round and maybe even smaller than the track height:

See the Pen by thebabydino (@thebabydino) on CodePen.

We can now see what the problem is: the endlines of the fill don’t coincide with the vertical midlines of the thumbs.

This is because of the way moving the thumb end-to-end works. In Chrome, the thumb’s border-box moves within the limits of the track’s content-box, while in Firefox, it moves within the limits of the slider’s content-box. This can be seen in the recordings below, where the padding is transparent, while the content-box and the border are semi-transparent. We’ve used orange for the actual slider, red for the track and purple for the thumb.

Animated gif. Chrome only moves the thumb within the left and right limits of the track's content-box.
Recording of the thumb motion in Chrome from one end of the slider to the other.

Note that the track’s width in Chrome is always determined by that of the parent slider – any width value we may set on the track itself gets ignored. This is not the case in Firefox, where the track can also be wider or narrower than its parent <input>. As we can see below, this makes it even more clear that the thumb’s range of motion depends solely on the slider width in this browser.

Animated gif. Firefox moves the thumb within the left and right limits of the actual range input's content-box.
Recording of the thumb motion in Firefox from one end of the slider to the other. The three cases are displayed from top to bottom. The border-box of the track perfectly fits the content-box of the slider horizontally. It’s longer and it’s shorter).

In our particular case (and, to be fair, in a lot of other cases), we can get away with not having any margin, border or padding on the track. That would mean its content-box coincides to that of the actual range input so there are no inconsistencies between browsers.

But what we need to keep in mind is that the vertical midlines of the thumbs (which we need to coincide with the fill endpoints) move between half a thumb width (or a thumb radius if we have a circular thumb) away from the start of the track and half a thumb width away from the end of the track. That’s an interval equal to the track width minus the thumb width (or the thumb diameter in the case of a circular thumb).

This can be seen in the interactive demo below where the thumb can be dragged to better see the interval its vertical midline (which we need to coincide with the fill’s endline) moves within.

See the Pen by thebabydino (@thebabydino) on CodePen.

The demo is best viewed in Chrome and Firefox.

The fill width and margin-left values are not relative to 100% (or the track width), but to the track width minus the thumb width (which is also the diameter in the particular case of a circular thumb). Also, the margin-left values don’t start from 0, but from half a thumb width (which is a thumb radius in our particular case).

$d: .5*$h; // thumb diameter
$r: .5*$d; // thumb radius
$uw: $w - $d; // useful width

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
	
  &::before {
    margin-left: calc(#{$r} + (var(--a) - var(--min))/var(--dif)*#{$uw});
    width: calc((var(--b) - var(--a))/var(--dif)*#{$uw});
  }
  
  &::after {
    margin-left: calc(#{$r} + (var(--b) - var(--min))/var(--dif)*#{$uw});
    width: calc((var(--a) - var(--b))/var(--dif)*#{$uw});
  }
}

Now the fill starts and ends exactly where it should, along the midlines of the two thumbs:

See the Pen by thebabydino (@thebabydino) on CodePen.

This one issue has been taken care of, but we still have a way bigger one. Let’s say we want to have more thumbs, say four:

Animated gif. Shows a slider with four thumbs which can pass each other and be in any order, while the fills are always between the two thumbs with the two smallest values and between the two thumbs with the two biggest values, regardless of their order in the DOM.
An example with four thumbs.

We now have four thumbs that can all pass each other and they can be in any order that we have no way of knowing. Moreover, we only have two pseudo-elements, so we cannot apply the same techniques. Can we still find a CSS-only solution?

Well, the answer is yes! But it means scrapping this solution and going for something different and way more clever — in part two of this article!

Article Series:

  1. Multi-Thumb Sliders: Particular Two-Thumb Case (This Post)
  2. Multi-Thumb Sliders: General Case (Coming Tomorrow!)

Multi-Thumb Sliders: Particular Two-Thumb Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
295854
Simple Swipe with Vanilla JavaScript https://css-tricks.com/simple-swipe-with-vanilla-javascript/ https://css-tricks.com/simple-swipe-with-vanilla-javascript/#comments Mon, 09 Apr 2018 13:00:46 +0000 http://css-tricks.com/?p=267821 I used to think implementing swipe gestures had to be very difficult, but I have recently found myself in a situation where I had to do it and discovered the reality is nowhere near as gloomy as I had imagined.…


Simple Swipe with Vanilla JavaScript originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
I used to think implementing swipe gestures had to be very difficult, but I have recently found myself in a situation where I had to do it and discovered the reality is nowhere near as gloomy as I had imagined.

This article is going to take you, step by step, through the implementation with the least amount of code I could come up with. So, let’s jump right into it!

The HTML Structure

We start off with a .container that has a bunch of images inside:

<div class='container'>
  <img src='img1.jpg' alt='image description'/>
  ...
</div>

Basic Styles

We use display: flex to make sure images go alongside each other with no spaces in between. align-items: center middle aligns them vertically. We make both the images and the container take the width of the container’s parent (the body in our case).

.container {
  display: flex;
  align-items: center;
  width: 100%;
  
  img {
    min-width: 100%; /* needed so Firefox doesn't make img shrink to fit */
    width: 100%; /* can't take this out either as it breaks Chrome */
  }
}

The fact that both the .container and its child images have the same width makes these images spill out on the right side (as highlighted by the red outline) creating a horizontal scrollbar, but this is precisely what we want:

Screenshot showing this very basic layout with the container and the images having the same width as the body and the images spilling out of the container to the right, creating a horizontal scrollbar on the body.
The initial layout (see live demo).

Given that not all the images have the same dimensions and aspect ratio, we have a bit of white space above and below some of them. So, we’re going to trim that by giving the .container an explicit height that should pretty much work for the average aspect ratio of these images and setting overflow-y to hidden:

.container {
  /* same as before */
  overflow-y: hidden;
  height: 50vw;
  max-height: 100vh;
}

The result can be seen below, with all the images trimmed to the same height and no empty spaces anymore:

Screenshot showing the result after limiting the container's height and trimming everything that doesn't fit vertically with overflow-y. This means we now have a horizontal scrollbar on the container itself.
The result after images are trimmed by overflow-y on the .container (see live demo).

Alright, but now we have a horizontal scrollbar on the .container itself. Well, that’s actually a good thing for the no JavaScript case.

Otherwise, we create a CSS variable --n for the number of images and we use this to make .container wide enough to hold all its image children that still have the same width as its parent (the body in this case):

.container {
  --n: 1;
  width: 100%;
  width: calc(var(--n)*100%);
  
  img {
    min-width: 100%;
    width: 100%;
    width: calc(100%/var(--n));
  }
}

Note that we keep the previous width declarations as fallbacks. The calc() values won’t change a thing until we set --n from the JavaScript after getting our .container and the number of child images it holds:

const _C = document.querySelector('.container'), 
      N = _C.children.length;

_C.style.setProperty('--n', N)

Now our .container has expanded to fit all the images inside:

Layout with expanded container (live demo).

Switching Images

Next, we get rid of the horizontal scrollbar by setting overflow-x: hidden on our container’s parent (the body in our case) and we create another CSS variable that holds the index of the currently selected image (--i). We use this to properly position the .container with respect to the viewport via a translation (remember that % values inside translate() functions are relative to the dimensions of the element we have set this transform on):

body { overflow-x: hidden }

.container {
  /* same styles as before */
  transform: translate(calc(var(--i, 0)/var(--n)*-100%));
}

Changing the --i to a different integer value greater or equal to zero, but smaller than --n, brings another image into view, as illustrated by the interactive demo below (where the value of --i is controlled by a range input):

See the Pen by thebabydino (@thebabydino) on CodePen.

Alright, but we don’t want to use a slider to do this.

The basic idea is that we’re going to detect the direction of the motion between the "touchstart" (or "mousedown") event and the "touchend" (or "mouseup") and then update --i accordingly to move the container such that the next image (if there is one) in the desired direction moves into the viewport.

function lock(e) {};

function move(e) {};

_C.addEventListener('mousedown', lock, false);
_C.addEventListener('touchstart', lock, false);

_C.addEventListener('mouseup', move, false);
_C.addEventListener('touchend', move, false);

Note that this will only work for the mouse if we set pointer-events: none on the images.

.container {
  /* same styles as before */

  img {
    /* same styles as before */
    pointer-events: none;
  }
}

Also, Edge needs to have touch events enabled from about:flags as this option is off by default:

Screenshot showing the 'Enable touch events' option being set to 'Only when a touchscreen is detected' in about:flags in Edge.
Enabling touch events in Edge.

Before we populate the lock() and move() functions, we unify the touch and click cases:

function unify(e) { return e.changedTouches ? e.changedTouches[0] : e };

Locking on "touchstart" (or "mousedown") means getting and storing the x coordinate into an initial coordinate variable x0:

let x0 = null;

function lock(e) { x0 = unify(e).clientX };

In order to see how to move our .container (or if we even do that because we don’t want to move further when we have reached the end), we check if we have performed the lock() action, and if we have, we read the current x coordinate, compute the difference between it and x0 and resolve what to do out of its sign and the current index:

let i = 0;

function move(e) {
  if(x0 || x0 === 0) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx);
  
    if((i > 0 || s < 0) && (i < N - 1 || s > 0))
      _C.style.setProperty('--i', i -= s);
	
    x0 = null
  }
};

The result on dragging left/ right can be seen below:

Animated gif. Shows how we switch to the next image by dragging left/ right if there is a next image in the direction we want to go. Attempts to move to the right on the first image or left on the last one do nothing as we have no other image before or after, respectively.
Switching between images on swipe (live demo). Attempts to move to the right on the first image or left on the last one do nothing as we have no other image before or after, respectively.

The above is the expected result and the result we get in Chrome for a little bit of drag and Firefox. However, Edge navigates backward and forward when we drag left or right, which is something that Chrome also does on a bit more drag.

Animated gif. Shows how Edge navigates the pageview backward and forward when we swipe left or right.
Edge navigating the pageview backward or forward on left or right swipe.

In order to override this, we need to add a "touchmove" event listener:

_C.addEventListener('touchmove', e => {e.preventDefault()}, false)

Alright, we now have something functional in all browsers, but it doesn’t look like what we’re really after… yet!

Smooth Motion

The easiest way to move towards getting what we want is by adding a transition:

.container {
  /* same styles as before */
  transition: transform .5s ease-out;
}

And here it is, a very basic swipe effect in about 25 lines of JavaScript and about 25 lines of CSS:

Working swipe effect (live demo).

Sadly, there’s an Edge bug that makes any transition to a CSS variable-depending calc() translation fail. Ugh, I guess we have to forget about Edge for now.

Refining the Whole Thing

With all the cool swipe effects out there, what we have so far doesn’t quite cut it, so let’s see what improvements can be made.

Better Visual Cues While Dragging

First off, nothing happens while we drag, all the action follows the "touchend" (or "mouseup") event. So, while we drag, we have no indication of what’s going to happen next. Is there a next image to switch to in the desired direction? Or have we reached the end of the line and nothing will happen?

To take care of that, we tweak the translation amount a bit by adding a CSS variable --tx that’s originally 0px:

transform: translate(calc(var(--i, 0)/var(--n)*-100% + var(--tx, 0px)))

We use two more event listeners: one for "touchmove" and another for "mousemove". Note that we were already preventing backward and forward navigation in Chrome using the "touchmove" listener:

function drag(e) { e.preventDefault() };

_C.addEventListener('mousemove', drag, false);
_C.addEventListener('touchmove', drag, false);

Now let’s populate the drag() function! If we have performed the lock() action, we read the current x coordinate, compute the difference dx between this coordinate and the initial one x0 and set --tx to this value (which is a pixel value).

function drag(e) {
  e.preventDefault();

  if(x0 || x0 === 0)  
    _C.style.setProperty('--tx', `${Math.round(unify(e).clientX - x0)}px`)
};

We also need to make sure to reset --tx to 0px at the end and remove the transition for the duration of the drag. In order to make this easier, we move the transition declaration on a .smooth class:

.smooth { transition: transform .5s ease-out; }

In the lock() function, we remove this class from the .container (we’ll add it again at the end on "touchend" and "mouseup") and also set a locked boolean variable, so we don’t have to keep performing the x0 || x0 === 0 check. We then use the locked variable for the checks instead:

let locked = false;

function lock(e) {
  x0 = unify(e).clientX;
  _C.classList.toggle('smooth', !(locked = true))
};

function drag(e) {
  e.preventDefault();
  if(locked) { /* same as before */ }
};

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0))
    _C.style.setProperty('--i', i -= s);
    _C.style.setProperty('--tx', '0px');
    _C.classList.toggle('smooth', !(locked = false));
    x0 = null
  }
};

The result can be seen below. While we’re still dragging, we now have a visual indication of what’s going to happen next:

Swipe with visual cues while dragging (live demo).

Fix the transition-duration

At this point, we’re always using the same transition-duration no matter how much of an image’s width we still have to translate after the drag. We can fix that in a pretty straightforward manner by introducing a factor f, which we also set as a CSS variable to help us compute the actual animation duration:

.smooth { transition: transform calc(var(--f, 1)*.5s) ease-out; }

In the JavaScript, we get an image’s width (updated on "resize") and compute for what fraction of this we have dragged horizontally:

let w;

function size() { w = window.innerWidth };

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0)) {
      _C.style.setProperty('--i', i -= s);
      f = 1 - f
    }
		
    _C.style.setProperty('--tx', '0px');
    _C.style.setProperty('--f', f);
    _C.classList.toggle('smooth', !(locked = false));
    x0 = null
  }
};

size();

addEventListener('resize', size, false);

This now gives us a better result.

Go back if insufficient drag

Let’s say that we don’t want to move on to the next image if we only drag a little bit below a certain threshold. Because now, a 1px difference during the drag means we advance to the next image and that feels a bit unnatural.

To fix this, we set a threshold at let’s say 20% of an image’s width:

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0) && f > .2) {
      /* same as before */
    }
		
    /* same as before */
  }
};

The result can be seen below:

We only advance to the next image if we drag enough (live demo).

Maybe Add a Bounce?

This is something that I’m not sure was a good idea, but I was itching to try anyway: change the timing function so that we introduce a bounce. After a bit of dragging the handles on cubic-bezier.com, I came up with a result that seemed promising:

Animated gif. Shows the graphical representation of the cubic Bézier curve, with start point at (0, 0), end point at (1, 1) and control points at (1, 1.59) and (.61, .74), the progression on the [0, 1] interval being a function of time in the [0, 1] interval. Also illustrates how the transition function given by this cubic Bézier curve looks when applied on a translation compared to a plain ease-out.
What our chosen cubic Bézier timing function looks like compared to a plain ease-out.
transition: transform calc(var(--f)*.5s) cubic-bezier(1, 1.59, .61, .74);
Using a custom CSS timing function to introduce a bounce (live demo).

How About the JavaScript Way, Then?

We could achieve a better degree of control over more natural-feeling and more complex bounces by taking the JavaScript route for the transition. This would also give us Edge support.

We start by getting rid of the transition and the --tx and --f CSS variables. This reduces our transform to what it was initially:

transform: translate(calc(var(--i, 0)/var(--n)*-100%));

The above code also means --i won’t necessarily be an integer anymore. While it remains an integer while we have a single image fully into view, that’s not the case anymore while we drag or during the motion after triggering the "touchend" or "mouseup" events.

Annotated screenshots illustrating what images we see for --i: 0 (1st image), --i: 1 (2nd image), --i: .5 (half of 1st and half of 2nd) and --i: .75 (a quarter of 1st and three quarters of 2nd).
For example, while we have the first image fully in view, --i is 0. While we have the second one fully in view, --i is 1. When we’re midway between the first and the second, --i is .5. When we have a quarter of the first one and three quarters of the second one in view, --i is .75.

We then update the JavaScript to replace the code parts where we were updating these CSS variables. First, we take care of the lock() function, where we ditch toggling the .smooth class and of the drag() function, where we replace updating the --tx variable we’ve ditched with updating --i, which, as mentioned before, doesn’t need to be an integer anymore:

function lock(e) {
  x0 = unify(e).clientX;
  locked = true
};

function drag(e) {
  e.preventDefault();
	
  if(locked) {
    let dx = unify(e).clientX - x0, 
      f = +(dx/w).toFixed(2);
		
    _C.style.setProperty('--i', i - f)
  }
};

Before we also update the move() function, we introduce two new variables, ini and fin. These represent the initial value we set --i to at the beginning of the animation and the final value we set the same variable to at the end of the animation. We also create an animation function ani():

let ini, fin;

function ani() {};

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
        s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);
		
    ini = i - s*f;

    if((i > 0 || s < 0) && (i < N - 1 || s > 0) && f > .2) {
      i -= s;
      f = 1 - f
    }

    fin = i;
    ani();
    x0 = null;
    locked = false;
  }
};

This is not too different from the code we had before. What has changed is that we’re not setting any CSS variables in this function anymore but instead set the ini and the fin JavaScript variables and call the animation ani() function.

ini is the initial value we set --i to at the beginning of the animation that the "touchend"/ "mouseup" event triggers. This is given by the current position we have when one of these two events fires.

fin is the final value we set --i to at the end of the same animation. This is always an integer value because we always end with one image fully into sight, so fin and --i are the index of that image. This is the next image in the desired direction if we dragged enough (f > .2) and if there is a next image in the desired direction ((i > 0 || s < 0) && (i < N - 1 || s > 0)). In this case, we also update the JavaScript variable storing the current image index (i) and the relative distance to it (f). Otherwise, it’s the same image, so i and f don’t need to get updated.

Now, let’s move on to the ani() function. We start with a simplified linear version that leaves out a change of direction.

const NF = 30;

let rID = null;

function stopAni() {
  cancelAnimationFrame(rID);
  rID = null
};

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*cf/NF);
	
  if(cf === NF) {
    stopAni();
    return
  }
	
  rID = requestAnimationFrame(ani.bind(this, ++cf))
};

The main idea here is that the transition between the initial value ini and the final one fin happens over a total number of frames NF. Every time we call the ani() function, we compute the progress as the ratio between the current frame index cf and the total number of frames NF. This is always a number between 0 and 1 (or you can take it as a percentage, going from 0% to 100%). We then use this progress value to get the current value of --i and set it in the style attribute of our container _C. If we got to the final state (the current frame index cf equals the total number of frames NF, we exit the animation loop). Otherwise, we just increment the current frame index cf and call ani() again.

At this point, we have a working demo with a linear JavaScript transition:

Version with linear JavaScript transition (live demo).

However, this has the problem we initially had in the CSS case: no matter the distance, we have to have to smoothly translate our element over on release ("touchend" / "mouseup") and the duration is always the same because we always animate over the same number of frames NF.

Let’s fix that!

In order to do so, we introduce another variable anf where we store the actual number of frames we use and whose value we compute in the move() function, before calling the animation function ani():

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
      s = Math.sign(dx), 
      f = +(s*dx/w).toFixed(2);
		
    /* same as before */

    anf = Math.round(f*NF);
    ani();

    /* same as before */
  }
};

We also need to replace NF with anf in the animation function ani():

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*cf/anf);
	
  if(cf === anf) { /* same as before */ }
	
  /* same as before */
};

With this, we have fixed the timing issue!

Version with linear JavaScript transition at constant speed (live demo).

Alright, but a linear timing function isn’t too exciting.

We could try the JavaScript equivalents of CSS timing functions such as ease-in, ease-out or ease-in-out and see how they compare. I’ve already explained in a lot of detail how to get these in the previously linked article, so I’m not going to go through that again and just drop the object with all of them into the code:

const TFN = {
  'linear': function(k) { return k }, 
  'ease-in': function(k, e = 1.675) {
    return Math.pow(k, e)
  }, 
  'ease-out': function(k, e = 1.675) {
    return 1 - Math.pow(1 - k, e)
  }, 
  'ease-in-out': function(k) {
    return .5*(Math.sin((k - .5)*Math.PI) + 1)
  }
};

The k value is the progress, which is the ratio between the current frame index cf and the actual number of frames the transition happens over anf. This means we modify the ani() function a bit if we want to use the ease-out option for example:

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*TFN['ease-out'](cf/anf));
	
  /* same as before */
};
Version with ease-out JavaScript transition (live demo).

We could also make things more interesting by using the kind of bouncing timing function that CSS cannot give us. For example, something like the one illustrated by the demo below (click to trigger a transition):

See the Pen by thebabydino (@thebabydino) on CodePen.

The graphic for this would be somewhat similar to that of the easeOutBounce timing function from easings.net.

Animated gif. Shows the graph of the bouncing timing function. This function has a slow, then accelerated increase from the initial value to its final value. Once it reaches the final value, it quickly bounces back by about a quarter of the distance between the final and initial value, then going back to the final value, again bouncing back a bit. In total, it bounces three times. On the right side, we have an animation of how the function value (the ordinate on the graph) changes in time (as we progress along the abscissa).
Graphical representation of the timing function.

The process for getting this kind of timing function is similar to that for getting the JavaScript version of the CSS ease-in-out (again, described in the previously linked article on emulating CSS timing functions with JavaScript).

We start with the cosine function on the [0, 90°] interval (or [0, π/2] in radians) for no bounce, [0, 270°] ([0, 3·π/2]) for 1 bounce, [0, 450°] ([0, 5·π/2]) for 2 bounces and so on… in general it’s the [0, (n + ½)·180°] interval ([0, (n + ½)·π]) for n bounces.

See the Pen by thebabydino (@thebabydino) on CodePen.

The input of this cos(k) function is in the [0, 450°] interval, while its output is in the [-1, 1] interval. But what we want is a function whose domain is the [0, 1] interval and whose codomain is also the [0, 1] interval.

We can restrict the codomain to the [0, 1] interval by only taking the absolute value |cos(k)|:

See the Pen by thebabydino (@thebabydino) on CodePen.

While we got the interval we wanted for the codomain, we want the value of this function at 0 to be 0 and its value at the other end of the interval to be 1. Currently, it’s the other way around, but we can fix this if we change our function to 1 - |cos(k)|:

See the Pen by thebabydino (@thebabydino) on CodePen.

Now we can move on to restricting the domain from the [0, (n + ½)·180°] interval to the [0, 1] interval. In order to do this, we change our function to be 1 - |cos(k·(n + ½)·180°)|:

See the Pen by thebabydino (@thebabydino) on CodePen.

This gives us both the desired domain and codomain, but we still have some problems.

First of all, all our bounces have the same height, but we want their height to decrease as k increases from 0 to 1. Our fix in this case is to multiply the cosine with 1 - k (or with a power of 1 - k for a non-linear decrease in amplitude). The interactive demo below shows how this amplitude changes for various exponents a and how this influences the function we have so far:

See the Pen by thebabydino (@thebabydino) on CodePen.

Secondly, all the bounces take the same amount of time, even though their amplitudes keep decreasing. The first idea here is to use a power of k inside the cosine function instead of just k. This manages to make things weird as the cosine doesn’t hit 0 at equal intervals anymore, meaning we don’t always get that f(1) = 1 anymore which is what we’d always need from a timing function we’re actually going to use. However, for something like a = 2.75, n = 3 and b = 1.5, we get a result that looks satisfying, so we’ll leave it at that, even though it could be tweaked for better control:

Screenshot of the previously linked demo showing the graphical result of the a = 2.75, n = 3 and b = 1.5 setup: a slow, then fast increase from 0 (for f(0)) to 1, bouncing back down less than half the way after reaching 1, going back up and then having another even smaller bounce before finishing at 1, where we always want to finish for f(1).
The timing function we want to try.

This is the function we try out in the JavaScript if we want some bouncing to happen.

const TFN = {
  /* the other function we had before */
  'bounce-out': function(k, n = 3, a = 2.75, b = 1.5) {
    return 1 - Math.pow(1 - k, a)*Math.abs(Math.cos(Math.pow(k, b)*(n + .5)*Math.PI))
  }
};

Hmm, seems a bit too extreme in practice:

Version with a bouncing JavaScript transition (live demo).

Maybe we could make n depend on the amount of translation we still need to perform from the moment of the release. We make it into a variable which we then set in the move() function before calling the animation function ani():

const TFN = {
  /* the other function we had before */
  'bounce-out': function(k, a = 2.75, b = 1.5) {
    return 1 - Math.pow(1 - k, a)*Math.abs(Math.cos(Math.pow(k, b)*(n + .5)*Math.PI))
  }
};

var n;

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
      s = Math.sign(dx), 
      f = +(s*dx/w).toFixed(2);
    
    /* same as before */
		
    n = 2 + Math.round(f)
    ani();
    /* same as before */
  }
};

This gives us our final result:

Version with the final bouncing JavaScript transition (live demo).

There’s definitely still room for improvement, but I don’t have a feel for what makes a good animation, so I’ll just leave it at that. As it is, this is now functional cross-browser (without have any of the Edge issues that the version using a CSS transition has) and pretty flexible.


Simple Swipe with Vanilla JavaScript originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/simple-swipe-with-vanilla-javascript/feed/ 24 267821
Using Conic Gradients and CSS Variables to Create a Doughnut Chart Output for a Range Input https://css-tricks.com/using-conic-gradients-css-variables-create-doughnut-chart-output-range-input/ https://css-tricks.com/using-conic-gradients-css-variables-create-doughnut-chart-output-range-input/#comments Fri, 02 Feb 2018 15:00:25 +0000 http://css-tricks.com/?p=265472 I recently came across this Pen and my first thought was that it could all be done with just three elements: a wrapper, a range input and an output. On the CSS side, this involves using a conic-gradient() with …


Using Conic Gradients and CSS Variables to Create a Doughnut Chart Output for a Range Input originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
I recently came across this Pen and my first thought was that it could all be done with just three elements: a wrapper, a range input and an output. On the CSS side, this involves using a conic-gradient() with a stop set to a CSS variable.

Animated gif. Shows the result we want to get: a vertical slider from a minimum of 0 to a maximum of 100, which we can drag to update a doughnut chart alongside it.
The result we want to reproduce.

In mid 2015, Lea Verou unveiled a polyfill for conic-gradient() during a conference talk where she demoed how they can be used for creating pie charts. This polyfill is great for getting started to play with conic-gradient(), as it allows us to use them to build stuff that works across the board. Sadly, it doesn’t work with CSS variables and CSS variables have become a key component of writing efficient code these days.

The good news is that things have moved a bit over the past two years and a half. Chrome and, in general, browsers using Blink that expose flags (like Opera for example) now support conic-gradient() natively (yay!), which means it has become possible to experiment with CSS variables as conic-gradient() stops. All we need to do is have the Experimental Web Platform Features flag enabled in chrome://flags (or, if you’re using Opera, opera://flags):

Screenshot showing the Experimental Web Platform Features flag being enabled in Chrome.
The Experimental Web Platform Features flag enabled in Chrome.

Alright, now we can get started!

The Initial Structure

We start with a wrapper element and a range input:

<div class="wrap">
  <input id="r" type="range"/>
</div>

Note that we don’t have an output element there. This is because we need JavaScript to update the value of the output element anyway and we don’t want to see an ugly useless non-updating element if the JavaScript is disabled or fails for some reason. So we add this element via JavaScript and also, based on whether the current browser supports conic-gradient() or not, we add a class on the wrapper to signal that.

If our browser supports conic-gradient(), the wrapper gets a class of .full and we style the output into a chart. Otherwise, we just have a simple slider without a chart, the output being on the slider thumb.

Screenshots. The top screenshot shows the result in browsers supporting conic-gradient: a chart output alongside the vertical slider. Dragging the slider updates the chart and the value on the slider thumb. The bottom screenshot shows the fallback case (when there is no native support for conic-gradient): a regular horizontal slider that only shows the value on the thumb and has no chart output.
The result in browsers supporting conic-gradient() (top) and the fallback in browsers not supporting it (bottom).

Basic Styles

Before anything else, we want to show a nice-looking slider on the screen in all browsers.

We start with the most basic reset possible and set a background on the body:

$bg: #3d3d4a;

* { margin: 0 }

body { background: $bg }

The second step is to prepare the slider for styling in WebKit browsers by setting -webkit-appearance: none on it and on its thumb (because the track already has it set by default for some reason) and we make sure we level the field by explicitly setting the properties that are inconsistent across browsers like padding, background or font:

[type='range'] {
  &, &::-webkit-slider-thumb { -webkit-appearance: none }
  
  display: block;	
  padding: 0;
  background: transparent;
  font: inherit
}

If you need a refresher on how sliders and their components work in various browsers, check out my detailed article on understanding the range input.

We can now move on to the more interesting part. We decide upon the dimensions of the track and thumb and set these on the slider components via the corresponding mixins. We’ll also include some background values so that we have something visible on the screen as well as a border-radius to prettify things. For both components, we also reset the border to none so that we have consistent results across the board.

$k: .1;
$track-w: 25em;
$track-h: .02*$track-w;
$thumb-d: $k*$track-w;

@mixin track() {
  border: none;
  width: $track-w; height: $track-h;
  border-radius: .5*$track-h;
  background: #343440
}

@mixin thumb() {
  border: none;
  width: $thumb-d; height: $thumb-d;
  border-radius: 50%;
  background: #e6323e
}

[type='range'] {
  /* same styles as before */
  width: $track-w; height: $thumb-d;
	
  &::-webkit-slider-runnable-track { @include track }
  &::-moz-range-track { @include track }
  &::-ms-track { @include track }
	
  &::-webkit-slider-thumb {
    margin-top: .5*($track-h - $thumb-d);
    @include thumb
  }
  &::-moz-range-thumb { @include thumb }
  &::-ms-thumb {
    margin-top: 0;
    @include thumb
  }
}

We add a few more touches like setting a margin, an explicit width and a font on the wrapper:

.wrap {
  margin: 2em auto;
  width: $track-w;
  font: 2vmin trebuchet ms, arial, sans-serif
}

We don’t want to let this get too small or too big, so we limit the font-size:

.wrap {
  @media (max-width: 500px), (max-height: 500px) { font-size: 10px }
  @media (min-width: 1600px), (min-height: 1600px) { font-size: 32px }
}

And we now have a nice cross-browser slider:

See the Pen by thebabydino (@thebabydino) on CodePen.

The JavaScript

We start by getting the slider and the wrapper and creating the output element.

const _R = document.getElementById('r'), 
      _W = _R.parentNode, 
      _O = document.createElement('output');

We create a variable val where we store the current value of our range input:

let val = null;

Next, we have an update() function that checks whether the current slider value is equal to the one we have already stored. If that’s not the case, we update the JavaScript val variable, the text content of the output and the CSS variable --val on the wrapper.

function update() {
  let newval = +_R.value;

  if(val !== newval)
    _W.style.setProperty('--val', _O.value = val = newval)
};

Before we move further with the JavaScript, we set a conic-gradient() on the output from the CSS:

output {
  background: conic-gradient(#e64c65 calc(var(--val)*1%), #41a8ab 0%)
}

We put things in motion by calling the update() function, adding the output to the DOM as a child of the wrapper and then testing whether the computed background-image of the output is the conic-gradient() we have set or not (note that we need to add it to the DOM before we do this).

If the computed background-image is not "none" (as it is the case if we have no native conic-gradient() support), then we add a full class on the wrapper. We also connect the output to the range input via a for attribute.

Via event listeners, we ensure the update() function is called every time we move the slider thumb.

_O.setAttribute('for', _R.id);
update();
_W.appendChild(_O);

if(getComputedStyle(_O).backgroundImage !== 'none')
  _W.classList.add('full');

_R.addEventListener('input', update, false);
_R.addEventListener('change', update, false);

We now have a slider and an output (that shows its value on a variable conic-gradient() background if we’re viewing it in a browser with native conic-gradient() support). Still ugly at this stage, but it’s functional—the output updates when we drag the slider:

See the Pen by thebabydino (@thebabydino) on CodePen.

We’ve also given the output a light color value so that we can see it better and added a % at the end via the ::after pseudo-element. We’ve also hidden the tooltip (::-ms-tooltip) in Edge by setting its display to none.

The no chart case

This is the case when we don’t have conic-gradient() support so we don’t have a chart. The result we’re aiming for can be seen below:

Animated gif. Shows the result we want to get when conic-gradient is not supported natively: a horizontal slider from a minimum of 0 to a maximum of 100, which we can drag to update the value displayed on its thumb.
The result we want to reproduce.

Prettifying the Output

In this case, we absolutely position the output element, make it take the dimensions of the thumb and put its text right in the middle:

.wrap:not(.full) {
  position: relative;
		
  output {    
    position: absolute;
    
    /* ensure it starts from the top */
    top: 0;
    
    /* set dimensions */
    width: $thumb-d; height: $thumb-d
  }
}

/* we'll be using this for the chart case too */
output {
  /* place text in the middle */
  display: flex;
  align-items: center;
  justify-content: center;
}

If you need a refresher on how align-items and justify-content work, check out this comprehensive article on CSS alignment by Patrick Brosset.

The result can be seen in the following Pen, where we’ve also set an outline in order to clearly see the boundaries of the output:

See the Pen by thebabydino (@thebabydino) on CodePen.

This is starting to look like something, but our output isn’t moving with the slider thumb.

Making the Output Move

In order to fix this problem, let’s first remember how the motion of a slider thumb works. In Chrome, the border-box of the thumb moves within the limits of the track’s content-box, while in Firefox and Edge, the thumb’s border-box moves within the limits of the actual slider’s content-box.

While this inconsistency may cause problems in some situations, our use case here is a simple one. We don’t have margins, paddings or borders on the slider or on its components, so the three boxes (content-box, padding-box and border-box) coincide with both the slider itself and its track and thumb components. Furthermore, the width of the three boxes of the actual input coincides with the width of the three boxes of its track.

This means that when the slider value is at its minimum (which we haven’t set explicitly, so it’s the default 0), the left edge of the thumb’s boxes coincides with the left edge of the input (and with that of the track).

Also, when the slider value is at its maximum (again, not set explicitly, so it takes the default value 100), the right edge of the thumb’s boxes coincides with the right edge of the input (and with that of the track). This puts the left edge of the thumb one thumb width ($thumb-d) before (to the left of) the right edge of the slider (and of the track).

The following illustration shows this relative to the input width ($track-w)—this is shown to be 1. The thumb width ($thumb-d) is shown as a fraction k of the input width (since we’ve set it as $thumb-d: $k*$track-w).

Illustration. On the left, it shows the slider thumb at the minimum value. In this case, the left edge of the thumb coincides with the left edge of the input and track. On the right, it shows the slider thumb at the maximum value. In this case, the right edge of the thumb coincides with the right edge of the input and track. The width of the thumb is a fraction k of the width of the track in both cases.
The slider thumb at the minimum value and at the maximum value (live).

From here, we get that the left edge of the thumb has moved by an input width ($track-w) minus a thumb width (thumb-d) in between the minimum and the maximum.

In order to move the output the same way, we use a translation. In its initial position, our output is at the leftmost position of the thumb, the one occupied when the slider value is at its minimum, so the transform we use is translate(0). To move it into the position occupied by the thumb when the slider value is at its maximum, we need to translate it by $track-w - $thumb-d = $track-w*(1 - $k).

Illustration. Shows that the range of motion of the thumb is the track width minus the thumb width as, in going from the minimum value (initial position) to the maximum value (final position), the left edge of the thumb goes from coinciding with slider's left edge to being one thumb width away from the slider's right edge. Since the distance between the slider's left and right edge is one track width, it results that the range of motion is the track width minus the thumb width. Given that the thumb width is a fraction k of the track width, the range of motion relative to the track width is 1 - k.
The range of motion for the slider thumb and, consequently, the output (live).

Alright, but what about the values in between?

Well, remember that every time the slider value gets updated, we’re not only setting the new value to the output‘s text content, but also to a CSS variable --val on the wrapper. This CSS variable goes between 0 at the left end (when the slider value is its minimum, 0 in this case) and 100 at the other end (when the slider value is its maximum, 100 in this case).

So if we translate our output along the horizontal (x) axis by calc(var(--val)/100*#{$track-w - $thumb-d}), this moves it along with the thumb without us needing to do anything else:

See the Pen by thebabydino (@thebabydino) on CodePen.

Note how the above works if we click elsewhere on the track, but not if we try to drag the thumb. This is because the output now sits on top of the thumb and catches our clicks instead.

We fix this problem by setting pointer-events: none on the output.

See the Pen by thebabydino (@thebabydino) on CodePen.

In the demo above, we have also removed the ugly outline on the output element as we don’t need it anymore.

Now that we have a nice fallback for browsers that don’t support conic-gradient() natively, we can move on to building the result we want for those that do (Chrome/ Opera with flag enabled).

The Chart Case

Drawing the Desired Layout

Before we start writing any code, we need to clearly know what we’re trying to achieve. In order to do that, we do a layout sketch with dimensions relative to the track width ($track-w), which is also the width of the input and the edge of the wrapper’s content-box (wrapper padding not included).

This means the content-box of our wrapper is a square of edge 1 (relative to the track width), the input is a rectangle having one edge along and equal to an edge of the wrapper and the other one a fraction k of the same edge, while its thumb is a kxk square.

Illustration. Shows the wrapper as a 1x1 square. The slider is vertical, right aligned horizontally, stretching from top to bottom vertically. Horizontally, it takes up a fraction k of the wrapper's width. The chart is left-aligned horizontally (its left edge goes along the left edge of the wrapper) and middle aligned vertically. The gaps from the chart to the top and bottom edges of the wrapper, as well as to the slider on the right, are all a fraction k of the wrapper's edge length. This makes the chart a square of edge 1 - 2·k.
The desired layout in the chart case (live).

The chart is a square of edge 1 - 2·k, touching the wrapper edge opposite to the slider, a k gap away from the slider and in the middle along the other direction. Given that the edge of the wrapper is 1 and that of the chart is 1 - 2·k, it results we have k gaps between the edges of the wrapper and those of the chart along this direction as well.

Sizing Our Elements

The first step towards getting this layout is making the wrapper square and setting the dimensions of the output to (1 - 2*$k)*100%:

$k: .1;
$track-w: 25em;
$chart-d: (1 - 2*$k)*100%;

.wrap.full {
  width: $track-w;
	
  output {
    width: $chart-d; height: $chart-d
  }
}

The result can be seen below, where we’ve also added some outlines to see things better:

Screenshot. We have a square wrapper containing an input and an output. The input is at the very top, its width equal to that of the wrapper, while its height is a fraction k of the wrapper's edge. The output is a square whose edge is a fraction 1 - 2*k of the wrapper's edge. It's positioned along the left edge of the wrapper, immediately under the input.
The result in a first stage (live demo, only if we have native conic-gradient() support).

This is a good start, as the output is already in the exact spot we want it to be.

Making the Slider Vertical

The “official” way of doing this for WebKit browsers is by setting -webkit-appearance: vertical on the range input. However, this would break the custom styles as they require us to have -webkit-appearance set to none and we cannot have it set to two different values at the same time.

So the only convenient solution we have is to use a transform. As it is, we have the minimum of the slider at the left end of the wrapper and the maximum at its right end. What we want is to have the minimum at the bottom of the wrapper and the maximum at the top of the wrapper.

Illustration. On the left, it shows the slider in it initial position, before applying any transform, at the top of the wrapper. The minimum is at the left end and the maximum is at the right end. On the right, we have the slider in its final position, along the right edge of the wrapper. The minimum is at the bottom and the maximum is at the top. This looks like a rotation in the negative direction (since the positive one would be clockwise).
The initial position of the slider vs. the final position we want to bring it to (live).

This sounds like a 90° rotation in the negative direction (as the clockwise direction is the positive one) around the top right corner (which gives us a transform-origin that’s at 100% horizontally and 0% vertically).

See the Pen by thebabydino (@thebabydino) on CodePen.

That’s a good start, but now our slider is outside the wrapper boundary. In order to decide what’s the best next step to bring it inside in the desired position, we need to understand what this rotation has done. Not only has it rotated the actual input element, but it has also rotated its local system of coordinates. Now its x axis points up and its y axis points to the right.

So in order to bring it inside, along the right edge of the wrapper, we need to translate it by its own height in the negative direction of its y axis after the rotation. This means the final transform chain we apply is rotate(-90deg) translatey(-100%). (Remember that % values used in translate() functions are relative to the dimensions of the translated element itself.)

.wrap.full {
  input {
    transform-origin: 100% 0;
    transform: rotate(-90deg) translatey(-100%)
  }
}

This gives us the desired layout:

Screenshot. We have a square wrapper containing an input and an output. The input is has been rotated such that its bottom edge now coincides with the wrapper's right edge, while its height takes up a fraction k of the top and bottom edges. The output is a square whose edge is a fraction 1 - 2*k of the wrapper's edge. It's positioned along the left edge of the wrapper and in the middle vertically.
The result in a second stage (live demo, only if we have native conic-gradient() support).

Styling the Chart

Of course, the first step is to make it round with border-radius and tweak the color, font-size and font-weight properties.

.wrap.full {
  output {
    border-radius: 50%;
    color: #7a7a7a;
    font-size: 4.25em;
    font-weight: 700
  }
}

You may have noticed we’ve set the dimensions of the chart as (1 - 2*$k)*100% instead of (1 - 2*$k)*$track-w. This is because $track-w is an em value, meaning that the computed pixel equivalent depends on the font-size of the element that uses it.

However, we wanted to be able to increase the font-size here without having to tweak down an em-valued size. This is possible and not that complicated, but compared to just setting the dimensions as % values that don’t depend on the font-size, it’s still a bit of extra work.

Screenshot. We have the same square wrapper with an input and an output. The output is now round and its text is grey and bigger.
The result in a third stage (live demo, only if we have native conic-gradient() support).

From Pie 🥧 to Doughnut 🍩

The simplest way to emulate that hole in the middle where we have the text is to add another background layer on top of the conic-gradient() one. We could probably add some blend modes to do the trick, but that’s not really necessary unless we have an image background. For a solid background as we have here, a simple cover layer will do.

$p: 39%;
background: radial-gradient($bg $p, transparent $p + .5% /* avoid ugly edge */),
            conic-gradient(#e64c65 calc(var(--val)*1%), #41a8ab 0%);

Alright, this does it for the chart itself!

Screenshot. We have the same square wrapper with an input and an output. The output is not a pie anymore, but a doughnut, looking like it has a hole in the middle.
The result in a fourth stage (live demo, only if we have native conic-gradient() support).

Showing the Value on the Thumb

We do this with an absolutely positioned ::after pseudo-element on the wrapper. We give this pseudo-element the dimensions of the thumb and position it in the bottom right corner of the wrapper, precisely where the thumb is when the slider value is at its minimum.

.wrap.full {
  position: relative;
  
  &::after {
    position: absolute;
    right: 0; bottom: 0;
    width: $thumb-d; height: $thumb-d;
    content: '';
  }
}

We also give it an outline just so that we can see it.

Screenshot. We have the same elements as before, plus a square pseudo-element whose edge is a fraction k of the wrapper edge. This pseudo-element is positioned in the bottom right corner of the wrapper.
The result in a fifth stage (live demo, only if we have native conic-gradient() support).

Moving it along with the thumb is achieved exactly the same as in the no chart case, except this time the translation happens along the y axis in the negative direction (instead of along the x axis in the positive direction).

transform: translatey(calc(var(--val)/-100*#{$track-w - $thumb-d}))

In order to be able to drag the thumb underneath, we have to also set pointer-events: none on this pseudo-element. The result can be seen below—dragging the thumb also moves the wrapper’s ::before pseudo-element.

Animated gif. We have the same elements as before and we drag the thumb to show click events pass through the pseudo-element placed on top of it.
The result in a sixth stage (live demo, only if we have native conic-gradient() support).

Alright, but what we really want here is to display the current value using this pseudo-element. Setting its content property to var(--val) does nothing, as --val is a number value, not a string. If we were to set it as a string, we could use it as a value for content, but then we couldn’t use it for calc() anymore.

Fortunately, we can get around this problem with a neat trick using CSS counters:

counter-reset: val var(--val);
content: counter(val)'%';

Now the whole thing is functional, yay!

Screenshot. We have the same elements as before, only now we also have the current value displayed on the thumb.
The result in a seventh stage (live demo, only if we have native conic-gradient() support).

So let’s move on to prettifying and adding some nice touches. We put the text in the middle of the thumb, we make it white, we get rid of all the outlines and we set cursor: pointer on the input:

.wrap.full {
  &::after {
    line-height: $thumb-d;
    color: #fff;
    text-align: center
  }
}

[type='range'] {
  /* same as before */
  cursor: pointer
}

This gives us the following nice result:

Screenshot. We have the same elements as before, only now we also have the current value displayed on the thumb, dead in the middle and all the outlines are gone.
The final look for the chart case (live demo, only if we have native conic-gradient() support).

Eliminating Repetition

One thing that’s nagging me is the fact that we have a bunch of common styles on the output in the no chart case and on the .wrap:after in the chart case.

Screenshot collage. Shows the styles on the output in the no chart case versus the styles on the .wrap:after in the chart case, highlighting the common ones.
Styles on the output in the no chart case vs. styles on the .wrap:after in the chart case.

We can do something about this and that’s using a silent class we then extend:

%thumb-val {
  position: absolute;
  width: $thumb-d; height: $thumb-d;
  color: #fff;
  pointer-events: none
}

.wrap {
  &:not(.full) output {
    @extend %thumb-val;
    /* same other styles */
  }

  &:after {
    @extend %thumb-val;
    /* same other styles */
  }
}

Nice Focus Styles

Let’s say we don’t want to have that ugly outline on :focus, but we also want to clearly differentiate this state visually. So what could we do? Well, let’s say we make the thumb smaller and a bit desaturated when the input isn’t focused and that we also hide the text in this case.

Sounds like a cool idea…but, since we have no parent selector, we cannot trigger a property change on the ::after of the slider’s parent when the slider gets or loses focus. Ugh.

What we can do instead is use the output‘s other pseudo-element (the ::before) to display the value on the thumb. This doesn’t come without any of its own complications, which we’ll discuss in a moment, but it allows us to do something like this:

[type='range']:focus + output:before { /* focus styles */ }

The problem with taking this approach is that we’re blowing up the font on the output itself, but for its ::before we need it to be the same size and weight as on the wrapper.

We can solve this by setting a relative font size as a Sass variable $fsr and then use that value to blow up the font on the actual output and bring it back down to its previous size on the output:before pseudo-element.

$fsr: 4;

.wrap {
  color: $fg;
	
  &.full {
    output {
      font-size: $fsr*1em;
      
      &:before {
        /* same styles as we had on .wrap:after */
        font-size: 1em/$fsr;
        font-weight: 200;
      }
    }
  }
}

Other than that, we just move the CSS declarations we had on the .wrap:after on the output:before.

Screenshot collage. Shows the styles on the .wrap.full:after (left) and highlights how all of them can be found afterwards on the .wrap.full output:before (right), in addition to those bringing the font down to the size and weight it has on the wrapper.
Styles on the wrapper pseudo-element vs. on the output pseudo-element.

Alright, now we can move on to the final step of differentiating between the normal and the focused look.

We start by hiding the ugly :focus state outline and the value on the thumb when the slider isn’t focused:

%thumb-val {
  /* same styles as before */
  opacity: 0;
}

[type='range']:focus {
  outline: none;
	
  .wrap:not(.full) & + output, 
  .wrap.full & + output:before { opacity: 1 }
}
Animated gif. The value on the thumb isn't visible when the slider isn't focused, as illustrated in this gif by clicking in and out of the boundaries of the range input.
The value on the thumb is only visible when the slider gets focus (live demo, only if we have native conic-gradient() support).

Next, we set different styles for the normal and focused states of the slider thumb:

@mixin thumb() {
  /* same styles as before */
  transform: scale(.7);
  filter: saturate(.7)
}

@mixin thumb-focus() {
  transform: none;
  filter: none
}

[type='range']:focus {
  /* same as before */
  &::-webkit-slider-thumb { @include thumb-focus }
  &::-moz-range-thumb { @include thumb-focus }
  &::-ms-thumb { @include thumb-focus }
}
Animated gif. The thumb is scaled down and desaturated as long as the slider isn't focused. Clicking on the range input to make it receive focus brings both the scale and saturation factor to 1.
The thumb is scaled down and desaturated as long as the slider isn’t focused (live demo, only if we have native conic-gradient() support).

The last step is to add a transition between these states:

$t: .5s;

@mixin thumb() {
  /* same styles as before */
  transition: transform $t linear, filter $t
}

%thumb-val {
  /* same styles as before */
  transition: opacity $t ease-in-out
}
Animated gif. Clicking within/ outside the boundaries of the range input causes it to gain/ lose focus and we have a transition between the two states.
The demo with a transition between the normal and focused state (live demo, only if we have native conic-gradient() support).

What About Screen Readers?

Since screen readers these days read generated content, we’d have the % value read twice in this case. So we go around this by setting role='img' on our output and then putting the current value that we want to be read in an aria-label attribute:

let conic = false;

function update() {
  let newval = +_R.value;

  if(val !== newval) {
    _W.style.setProperty('--val', _O.value = val = newval);
    if(conic) _O.setAttribute('aria-label', `${val}%`)
  }
};

update();

_O.setAttribute('for', _R.id);
_W.appendChild(_O);

if(getComputedStyle(_O).backgroundImage !== 'none') {
  conic = true;
  _W.classList.add('full');
  _O.setAttribute('role', 'img');
  _O.setAttribute('aria-label', `${val}%`)
}

The final demo can be found below. Note that we only see the fallback if your browser has no native conic-gradient() support

See the Pen by thebabydino (@thebabydino) on CodePen.

Final Words

While the browser support for this is still poor, the situation will change. For now it’s just Blink browsers that expose flags, but Safari lists conic-gradient() as being in development, so things are already getting better.

If you’d like cross-browser support to become a reality sooner rather than later, you can contribute by voting for conic-gradient() implementation in Edge or by leaving a comment on this Firefox bug on why you think this is important or what use cases you have in mind. here are mine for inspiration.


Using Conic Gradients and CSS Variables to Create a Doughnut Chart Output for a Range Input originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/using-conic-gradients-css-variables-create-doughnut-chart-output-range-input/feed/ 3 265472
You can get pretty far in making a slider with just HTML and CSS https://css-tricks.com/can-get-pretty-far-making-slider-just-html-css/ https://css-tricks.com/can-get-pretty-far-making-slider-just-html-css/#comments Thu, 12 Oct 2017 13:54:04 +0000 http://css-tricks.com/?p=260709 A “slider”, as in, a bunch of boxes set in a row that you can navigate between. You know what a slider is. There are loads of features you may want in a slider. Just as one example, you might …


You can get pretty far in making a slider with just HTML and CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
A “slider”, as in, a bunch of boxes set in a row that you can navigate between. You know what a slider is. There are loads of features you may want in a slider. Just as one example, you might want the slider to be swiped or scrolled. Or, you might not want that, and to have the slider only respond to click or tappable buttons that navigate to slides. Or you might want both. Or you might want to combine all that with autoplay.

I’m gonna go ahead and say that sliders are complicated enough of a UI component that it’s use JavaScript territory. Flickity being a fine example. I’d also say that you can get pretty far with a nice looking functional slider with HTML and CSS alone. Starting that way makes the JavaScript easier and, dare I say, a decent example of progressive enhancement.

Let’s consider the semantic markup first.

A bunch of boxes is probably as simple as:

<div class="slider">
  <div class="slide" id="slide-1"></div>
  <div class="slide" id="slide-2"></div>
  <div class="slide" id="slide-3"></div>
  <div class="slide" id="slide-4"></div>
  <div class="slide" id="slide-5"></div>
</div>

With a handful of lines of CSS, we can set them next to each other and let them scroll.

.slider {
  width: 300px;
  height: 300px;
  display: flex;
  overflow-x: auto;
}
.slide {
  width: 300px;
  flex-shrink: 0;
  height: 100%;
}

Might as well make it swipe smoothly on WebKit based mobile browsers.

.slider {
  ...

  -webkit-overflow-scrolling: touch;
}

We can do even better!

Let’s have each slide snap into place with snap-points.

This has changed a little bit since the original posting! I’m updating the code blocks below.

.slider {
  ...

  /* CURRENT way. */
  scroll-snap-type: x mandatory;  

  /* OLD WAY. Probably not worth including at all.

  -webkit-scroll-snap-points-x: repeat(300px);
      -ms-scroll-snap-points-x: repeat(300px);
          scroll-snap-points-x: repeat(300px);
  -webkit-scroll-snap-type: mandatory;
      -ms-scroll-snap-type: mandatory;
          scroll-snap-type: mandatory;

   */

}

.slides > div {
  /* CURRENT way. */
  scroll-snap-align: start;
}

Look how much nicer it is now:

Jump links

A slider probably has a little UI to jump to a specific slide, so let’s do that semantically as well, with anchor links that jump to the correct slide:

<div class="slide-wrap">
  
  <a href="#slide-1">1</a>
  <a href="#slide-2">2</a>
  <a href="#slide-3">3</a>
  <a href="#slide-4">4</a>
  <a href="#slide-5">5</a>

  <div class="slider">
    <div class="slide" id="slide-1">1</div>
    <div class="slide" id="slide-2">2</div>
    <div class="slide" id="slide-3">3</div>
    <div class="slide" id="slide-4">4</div>
    <div class="slide" id="slide-5">5</div>
  </div>
  
</div>

Anchor links that actually behave as a link to related content and semantic and accessible so no problems there (feel free to correct me if I’m wrong).

Let’s style thing up a little bit… and we got some buttons that do their job:

On both desktop and mobile, we can still make sure we get smooth sliding action, too!

.slides {
  ...
  
  scroll-behavior: smooth;
}

Maybe we’d only display the buttons in situations without nice snappy swiping?

If the browser supports scroll-snap-type, it’s got nice snappy swiping. We could just hide the buttons if we wanted to:

@supports (scroll-snap-type) {
  .slider > a {
    display: none;
  }
}

Need to do something special to the “active” slide?

We could use :target for that. When one of the buttons to navigate slides is clicked, the URL changes to that #hash, and that’s when :target takes effect. So:

.slides > div:target {
  transform: scale(0.8);
}

There is a way to build this slide with the checkbox hack as well, and still to “active slide” stuff with :checked, but you might argue that’s a bit less semantic and accessible.

Here’s where we are so far.

See the Pen
Real Simple Slider
by Chris Coyier (@chriscoyier)
on CodePen.

This is where things break down a little bit.

Using :target is a neat trick, but it doesn’t work, for example, when the page loads without a hash. Or if the user scrolls or flicks on their own without using the buttons. I both don’t think there is any way around this with just HTML and CSS, nor do I think that’s entirely a failure of HTML and CSS. It’s just the kind of thing JavaScript is for.

JavaScript can figure out what the active slide is. JavaScript can set the active slide. Probably worth looking into the Intersection Observer API.

What are more limitations?

We’ve about tapped out what HTML and CSS alone can do here.

  • Want to be able to flick with a mouse? That’s not a mouse behavior, so you’ll need to do all that with DOM events. Any kind of exotic interactive behavior (e.g. physics) will require JavaScript. Although there is a weird trick for flipping vertical scrolling for horizontal.
  • Want to know when a slide is changed? Like a callback? That’s JavaScript territory.
  • Need autoplay? You might be able to do something rudimentary with a checkbox, :checked, and controlling the animation-play-state of a @keyframes animation, but it will feel limited and janky.
  • Want to have it infinitely scroll in one direction, repeating as needed? That’s going to require cloning and moving stuff around in the DOM. Or perhaps some gross misuse of <marquee>.

I’ll leave you with those. My point is only that there is a lot you can do before you need JavaScript. Starting with that strong of a base might be a way to go that provides a happy fallback, regardless of what you do on top of it.


You can get pretty far in making a slider with just HTML and CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/can-get-pretty-far-making-slider-just-html-css/feed/ 18 260709
Carousels Don’t Have to be Complicated https://css-tricks.com/carousels-dont-complicated/ Fri, 28 Oct 2016 14:45:10 +0000 http://css-tricks.com/?p=247145 Over on the MediaTemple blog, I show you how you can make a pretty decent little carousel with just a few lines of code. Here’s the entire premise:

Every time a carousel is mentioned in a blog post, it’s a


Carousels Don’t Have to be Complicated originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Over on the MediaTemple blog, I show you how you can make a pretty decent little carousel with just a few lines of code. Here’s the entire premise:

Every time a carousel is mentioned in a blog post, it’s a requirement to mention that… say it with me now:

You probably shouldn’t use a carousel.

Jared Smith made a microsite just for it, with research and rhetoric supporting the idea that you shouldn’t use them. Most of that information focuses on the fact that there is low engagement with the non-first-slide(s).

I’m not here to argue with data, but I would argue with the dogmatism of “never user them, EVER.” Swiping on mobile is an extremely common action, and what is a swipe that reveals more content? Basically a carousel. What about a carousel that doesn’t demand much interaction? Perhaps just a way to swipe through an artist’s recent work. This seems like a perfectly nice way to do that, so long as the UI is clear and accessibility is implemented.

What I am here to talk about is the situation where you do want a carousel and to resist the temptation to reach for a wheelbarrow full of code to do so. I guarantee there are people who’ve picked an entire CMS because they thought they needed it to make a carousel. No shame. We’re all learning.

I have good news: Carousels don’t have to be complicated. They don’t have to require a ton of code or do anything that you can’t wrap your head around with basic HTML, CSS, and JavaScript knowledge.

It’s not just my idea, I link out to all the smart people who have tackled this subject before throughout the years and made similarly simple and awesome demos.

To Shared LinkPermalink on CSS-Tricks


Carousels Don’t Have to be Complicated originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
247145