DEV Community

loading...
Cover image for Making an animated slider - WotW

Making an animated slider - WotW

ederchrono profile image Eder Díaz Updated on ・8 min read

Welcome to the Widget of the Week series, where I take gifs or videos of awesome UI/UX components, and bring them to life with code.

This time we are going to create a temperature slider, although it can be used for anything.
The inspiration comes from this submission created by ramykhuffash and looks like this:

wotw-slider

Preparations

For today's widget we will be using Vue.js, and for some animations we'll use TweenMax. Also we will need a temperature icon, so we'll be using the one from Font Awesome.

If you want to follow along you can fork this codepen template that already has the dependencies.

Matching the design

The HTML markup for this widget got a little bit more complicated than the usual, so this time I will break it down in sections using HTML + CSS until we match the original design.

Let's start by setting an upper and lower section, the upper one will contain the numbers and the lower one the slider control.

<div id="app" class="main-container">
  <div class="upper-container">

  </div>
  <div class="lower-container">

  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Before styling them we need a couple of main CSS properties in the body.

body {
  margin: 0;
  color: white;
  font-family: Arial, Helvetica, sans-serif;
}
Enter fullscreen mode Exit fullscreen mode

We set the margin to 0 to avoid having a gap surrounding our main-container. The color and font-family is set there too to avoid repeating them all over our elements.

Now we will use CSS grid properties to divide the screen in two parts, the upper one needs to take something like 3/4 of the vertical height, we can achieve that with fr.

.main-container {
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 3fr 1fr;
  height: 100vh;
  overflow-x: hidden;
}
Enter fullscreen mode Exit fullscreen mode

Notice the 100vh value in the height property, that allows us to fill the screen vertically even if our divs have no content at all.

Also the overflow-x: hidden property will prevent our widget to show a scroll bar if our elements get out of bounds in smaller screens (Thanks to Nested Software for noticing this).

Now it is just a matter of adding a background color to the sections. For the upper one we'll be using a gradient:

.upper-container {
  position: relative;
  background: linear-gradient(to bottom right, #5564C2, #3A2E8D);
}
.lower-container {
  background-color: #12132C;
}
Enter fullscreen mode Exit fullscreen mode

The position: relative property set in the upper-container will be of use when we tray to position its inner elements.

bgs
We're just getting warmed up.

The numbers inside the upper section seem like the logical next step.

  <!-- inside .upper-container -->
    <h2 class="temperature-text">10</h2>
Enter fullscreen mode Exit fullscreen mode

This will be the big number that shows the current temperature, let's use some CSS to position it better:

.temperature-text {
  position: absolute;
  bottom: 150px;
  font-size: 100px;
  width: 100%;
  text-align: center;
  user-select: none;
}
Enter fullscreen mode Exit fullscreen mode

The user-select: none property should helps us to avoid selecting the text when we interact with the slider.

Before we adding the numbers that appear below, let's fire up the Vue instance with some data to help us avoid repeating unnecessary markup elements:

new Vue({
  el: '#app',
  data: {
    temperatureGrades: [10, 15, 20, 25, 30]
  }
})
Enter fullscreen mode Exit fullscreen mode

Now we can use that temperatureGrades array to show those elements in the design:

    <!-- just after .temperature-text -->
    <div class="temperature-graduation">
      <div class="temperature-element" 
           v-for="el in temperatureGrades" 
           :key="el">
        <span class="temperature-element-number">{{el}}</span><br>
        <span class="temperature-element-line">|</span>
      </div>
    </div>
Enter fullscreen mode Exit fullscreen mode

Notice that we are rendering for each of the numbers a | character, now we can style them to look like a "ruler".

For both the numbers and lines we need to center the text, we will do that inside the temperature-element rules. We will also make the elements to be inline-blocks so they can be next to each other. Finally the | character needs to be smaller, font-size will take care of that:

.temperature-element {
  text-align: center;
  display: inline-block;
  width: 40px;
  margin: 0 10px 0 10px;
  opacity: 0.7;
}
.temperature-element-line {
  font-size: 7px;
}
Enter fullscreen mode Exit fullscreen mode

Inspecting the .temperature-graduation element we can see that it's width is 300px, in order to center it we can use a calculated value in the following way:

.temperature-graduation {
  position: absolute;
  left: calc(50% - 150px); // subtracting half the width to center
  bottom: 25px;
  user-select: none;
}
Enter fullscreen mode Exit fullscreen mode

We also set the bottom property to make it appear just above the lower section.

numbers-ready

The slider

The upper part is ready, now we will add the slider control. The button is easy, we just need a div with an icon in it:

  <!-- inside .lower-container -->
    <div class="slider-container">
      <div class="slider-button">
        <i class="fas fa-thermometer-empty slider-icon"></i>
      </div>
    </div>
Enter fullscreen mode Exit fullscreen mode

Now let's style the button, most of the following CSS code are values "tweaked" by hand to be able to position the elements in the desired position.

.slider-container {
  width: 150px;
  height: 80px;
  margin-top: -30px;
  margin-left: calc(50% - 187px);
  position: relative;
}
.slider-button {
  position: absolute;
  left: 42px;
  top: 5px;
  width: 50px;
  height: 50px;
  border-radius: 50%;
  background-color: #2724A2;

  cursor: grab;
  cursor: -webkit-grab; 
  cursor: -moz-grab;
}

.slider-icon {
  margin-top: 16px;  
  margin-left: 21px;  
  color: white;
}
Enter fullscreen mode Exit fullscreen mode

The grab values inside the button will transform the cursor to be a hand when hovering it.

The slider now is only missing a "wave" like shape, at first I tried to do it by using border-radius values and rotating a div, but sadly it didn't match the design. What I ended up doing was a SVG graphic that looks like this:

little-prince

The code for that shape is this:

    <!-- inside .slider-container -->
      <svg width="150" height="30" viewBox="0 0 150 30" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M74.3132 0C47.0043 2.44032e-05 50.175 30 7.9179 30H144.27C99.4571 30 101.622 -2.44032e-05 74.3132 0Z" transform="translate(-7.38794 0.5)" fill="#12132C"/>
      </svg>
Enter fullscreen mode Exit fullscreen mode

design-ready
It's been a bit of a ride, but we have the design ready.

The interaction

The most noticeable thing in the interaction of this widget is, by far, dragging and dropping the slider. We have done this before when we did the card slider, so I'll follow a similar approach:

  // inside data
    dragging: false,
    initialMouseX: 0,
    sliderX: 0,
    initialSliderX: 0
Enter fullscreen mode Exit fullscreen mode

These will be the data properties that will help us to keep track when the user starts/stops dragging, mouse and the slider position.

The following methods will initialize those variables when the user interacts:

  // after data
  methods: {
    startDrag (e) {
      this.dragging = true
      this.initialMouseX = e.pageX
      this.initialSliderX = this.sliderX
    },
    stopDrag () {
      this.dragging = false
    },
    mouseMoving (e) {
      if(this.dragging) {
        // TODO move the slider        
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Now let's bind them to the template

<div id="app" class="main-container"
    @mousemove="mouseMoving"
    @mouseUp="stopDrag">
      <!-- ... inside .slider-container
        <div class="slider-button" 
             @mouseDown="startDrag">

Enter fullscreen mode Exit fullscreen mode

You might have noticed that the @mouseDown action is set in the slider button, but the @mouseMove and @mouseUp are at the level of the main div.

The reason behind this is that users will start by pressing down the slider button, but when moving the cursor they usually get outside the slider track, and if they let go the mouse outside the button it won't be tracked and will cause the button to follow you until you click it again.

Let's now fill the mouseMoving method with an algorithm that will set the sliderX property to the desired position. We will need to declare some constrains for the slider to match the ruler we did before.

// before the Vue instance
const sliderMinX = 0
const sliderMaxX = 240

  // inside mouseMoving method
    // replace the "TODO" line with this:
    const dragAmount = e.pageX - this.initialMouseX
    const targetX = this.initialSliderX + dragAmount

    // keep slider inside limits
    this.sliderX = Math.max(Math.min(targetX, sliderMaxX), sliderMinX)

  // after methods
  computed: {
    sliderStyle () {
      return `transform: translate3d(${this.sliderX}px,0,0)`
    }
  }
Enter fullscreen mode Exit fullscreen mode

The computed property sliderStyle as you might have guessed, stores the position for the slider, we just need to bind it to the .slider-container:

  <div class="slider-container" :style="sliderStyle">
Enter fullscreen mode Exit fullscreen mode

We almost have a working slider control but it is missing an important thing, tracking the slider value. It might sound complicated, but we can calculate that value with a computed property because we already know the sliderX position:

  // inside computed    
    currentTemperature () {
      const tempRangeStart = 10
      const tempRange = 20 // from 10 - 30
      return (this.sliderX / sliderMaxX * tempRange ) + tempRangeStart
    }
Enter fullscreen mode Exit fullscreen mode

You can se that it works by render it inside the .temperature-text element:

  <h2 class="temperature-text">{{currentTemperature}}</h2>
Enter fullscreen mode Exit fullscreen mode

float
The problem now is that it is rendering float numbers. We can avoid that with a filter:

  // after data
  filters: {
    round (num) {
      return Math.round(num)
    }
  },
Enter fullscreen mode Exit fullscreen mode

And now we can use the filter like this:

  <h2 class="temperature-text">{{currentTemperature | round}}</h2>
Enter fullscreen mode Exit fullscreen mode

The finishing touches

We could call it a day and let the widget like this, but it is still missing a couple of details.
The background should change colors when the temperature goes above 25 degrees, and also the ruler numbers should move in a wave like manner.

For the background we'll declare a couple of constants at the top and some new data properties:

const coldGradient = {start: '#5564C2', end: '#3A2E8D'}
const hotGradient = {start:'#F0AE4B', end: '#9B4D1B'}

// inside Vue
    // inside data
      gradientStart: coldGradient.start,
      gradientEnd: coldGradient.end

    //inside computed
      bgStyle () {
        return `background: linear-gradient(to bottom right, ${this.gradientStart}, ${this.gradientEnd});`
      }
Enter fullscreen mode Exit fullscreen mode

They will hold the colors needed for the gradient background. The bgStyle computed property will generate the background every time gradientStart and gradientEnd change. Let's bind it to it's corresponding HTML element:

  <div class="upper-container" :style="bgStyle">
Enter fullscreen mode Exit fullscreen mode

For now it should look the same, but that will change when we add the rules to animate it inside the mouseMoving method:

    // set bg color
    let targetGradient = coldGradient
    if (this.currentTemperature >= 25) {
      targetGradient = hotGradient
    }

    if(this.gradientStart !== targetGradient.start) {
      // gradient changed
      TweenLite.to(this, 0.7, {
        'gradientStart': targetGradient.start,
        'gradientEnd': targetGradient.end
      }) 
    }
Enter fullscreen mode Exit fullscreen mode

What we are doing is change the gradient values from the cold to the hot ones, when the temperature changes to be 25 degrees or more. The transition is done with TweenLite instead of CSS transitions, because they only work with solid colors.

Finally our rulers elements need to change their Y position if the slider is close to them.

    <div class="temperature-element" v-for="el in temperatureGrades"
           :style="tempElementStyle(el)"
           :key="el">
Enter fullscreen mode Exit fullscreen mode

Similarly to the upper section we'll bind the style to be changed by a method, this method will receive each of the ruler's values. Now it's just a matter of doing some math to calculate the distance and generate some CSS transform props:

  // inside methods
    tempElementStyle (tempNumber) {
      const nearDistance = 3
      const liftDistance = 12

      // lifts up the element when the current temperature is near it
      const diff = Math.abs(this.currentTemperature - tempNumber)
      const distY = (diff/nearDistance) - 1

      // constrain the distance so that the element doesn't go to the bottom
      const elementY = Math.min(distY*liftDistance, 0)
      return `transform: translate3d(0, ${elementY}px, 0)`
    }
Enter fullscreen mode Exit fullscreen mode

And now the final result!

That’s it for this Widget of the Week.

If you're hungry for more you can check other WotW:

Also if you want to see a specific widget for next week, post it in the comments section.

Discussion (4)

Collapse
nestedsoftware profile image
Nested Software • Edited

This is a really neat demo!

I wonder if there's a different way to move the slider, i.e. this code:

    sliderStyle () {
      return `transform: translate3d(${this.sliderX}px,0,0)`
    },

I've noticed that in Chrome at least, when the browser is not expanded to its maximum size, moving the slider causes a horizontal scrollbar to appear. I'd have to play around with it myself, but I wonder if changing the position in some other way would fix that problem...

Collapse
ederchrono profile image
Eder Díaz Author • Edited

You're right, I hadn't noticed that. It can easily be fixed by adding overflow-x: hidden; to the .main-container CSS rules. I just made those adjustments in the post, thanks!

Collapse
nestedsoftware profile image
Nested Software • Edited

I used your code to create a React version :)

It's a bit rough but it works.

In the process I noticed a small potential issue. I think if(this.gradientStart !== targetGradient.start) may be executed several times since the transition is not instantaneous. It doesn't seem to be noticeable to the user, but I modified this logic with an extra variable to make sure we only run the animation once per transition.

if(this.activeGradientStart !== targetGradient.start) {
this.activeGradientStart = targetGradient.start

Collapse
ederchrono profile image
Eder Díaz Author

Awesome, and also nice catch. As you said, it might not be noticeable but on a larger system (or slower hardware) it could have issues.
The extra variable would be the way to go for me too.

Forem Open with the Forem app