Implementation: UI: Horizontal page slider
This article was initially published on CodePen on October 21th, 2017
After exploring full page sliding in its vertical fashion, let’s look at a more sophisticated and horizontal one.
We’ll first look into a pure CSS one:
then will add vanilla Javascript to provide a version that supports a swipe gesture on our favorite touch-enabled devices (smartphones, tablets and strange things that are between none and all of these).
So this later one should be tested on touch devices.
Pure CSS
The state
Sliders mean selected slide, and selected states, without JS, means hijacking those of regular HTML inputs through the (in)famous “checkbox hack”. In case of exclusive states (which is ours), assume radio buttons are a special case of this (both are using the same checked
attribute).
The idea behind that is to declare your states as a group of radio buttons:
<input type="radio" id="my-tree" name="slide-choice">
<input type="radio" id="profile" name="slide-choice">
<input type="radio" id="people-select" name="slide-choice">
<input type="radio" id="leaves" name="slide-choice">
that will never be visible:
input[type="radio"] {
position: fixed;
top: -100vh;
}
then to associate them <label>
s that will act as the visible, custom-rendered of the states. In our case, the dots that will represent the number of slides and highlight the selected one:
<label for="my-tree">●</label>
<label for="profile">●</label>
<label for="people-select">●</label>
<label for="leaves">●</label>
How this? Because of a useful property of labels: if they are properly associated with <input>
s, clicking on them is like clicking on those inputs. For instance, when you click on the label for profile
, you select the profile
input in the radio group.
All you have to do to render a selection, then, is to provide different styles for labels of selected and unselected radios inputs:
label {
// Unselected style, by default
opacity: 0.5;
}
#my-tree:checked ~ label[for="my-tree"],
#profile:checked ~ label[for="profile"],
#people-select:checked ~ label[for="people-select"],
#leaves:checked ~ label[for="leaves"] {
// Selected style
opacity: 1;
}
The slider
Quite unsurprisingly, an horizontal slider is a kind of horizontal projection of a vertical slider. That this, the displayed slide is just a viewport on a larger division containing all slides, which we’ll call the .slider
:
<div class="slider">
<div class="slide" id="my-tree-slide">
<!-- HTML contents of first slide -->
</div>
<div class="slide" id="profile-slide">
<!-- HTML contents of second slide -->
</div>
<div class="slide" id="people-select-slide">
<!-- HTML contents of third slide -->
</div>
<div class="slide" id="leaves-slide">
<!-- HTML contents of fourth and last slide -->
</div>
</div>
.slider {
position: fixed;
width: 4 * 100vw; // Enough width to store 4 full-width slides
transform: translateX(0); // Start from most left (and set hardware accelerated animations)
}
.slide {
float: left; // Put each slide on the left of the next one
height: 100vh; // Full viewport height slide
width: 100vw; // Full viewport width slide
}
then you actually scroll the viewport over it. Or, as relativity puts it, is it the .slider
that moves backwards? Both methods exist and we'll use the later one. Now that you know the trick of binding a input state to such style a neighbour element, all you have to do is to require a different (hardware-accelerated) translation for each of them:
#my-tree:checked ~ .slider {
transform: translateX(0); // Visible part is the 1st slide
}
#profile:checked ~ .slider {
transform: translateX(-100vw); // Translate back to show 2nd slide
}
#people-select:checked ~ .slider {
transform: translateX(-200vw); // Translate back to show 3rd slide
}
#leaves:checked ~ .slider {
transform: translateX(-300vw); // Translate back to show 4th slide
}
Implementing gestures
Unfortunately, while waiting for full support of CSS “snap” properties, you need Javascript to implement touch-dragging handling.
Basically you have to listen to 3 touch events (unfortunately Pointer Eventsare not supported on all major browsers at the time this post is written):
touchstart
when the user starts to touch to slide. We'll remember the x position of this intouchStartX
;touchmove
when the user drags his/her finger to slide ;touchend
when the user releases his/her finger.
slider.addEventListener('touchstart', swipeStart);
slider.addEventListener('touchmove', swipeDrag);
slider.addEventListener('touchend', swipeEnd);
During this time we’ll maintain the following variables:
index
is the index of the current slide (the one being displayed), starting from0
.currentTx
is the absolute position (in pixels) of the current slide inside the.slider
. So it is equal toindex * -slide.offsetWidth
, where slide is the slide element.tx
is the "local" translation inside the viewport. That is, how much the user is moving from it's touch start. So it is equal totouchMoveX - touchStartX
.
function swipeStart(touchEvent) {
touchStartX = touchEvent.touches[0].pageX;
currentTx = index * -slide.offsetWidth;
}
function swipeDrag(touchEvent) {
var touchMoveX = touchEvent.touches[0].pageX;
tx = touchMoveX - touchStartX; // Will be < 0 if scrolling to right
}
By the way, don’t consider storing slide.offsetWidth
in a variable, as it would prevent its value to be updated (when the viewport changes size, notably when orientation changes).
Dragging
Now that we have required data during drag, how to we translate the slides?
function swipeDrag(touchEvent) {
// ...
var fullTx = currentTx + tx;
if (fullTx < 0) {
slider.style.transform = 'translateX(' + fullTx + 'px)';
}
}
For the sake of animation smoothness, you’ll want to request a proper animation time frame when issuing your translation statement:
window.requestAnimationFrame(function() {
slider.style.transform = 'translateX(' + fullTx + 'px)';
});
Dropping
This is when the user releases his/her finger that we have to devise which slide to select. To do so we have to decide which portion of a scrolled slide is enough to select it. While it would seem logical to decide to select a slide when more than its half is translated, a comfortable swipe actually requires much less to select, as soon as 1/4 or even 1/8 of it has been revealed:
function swipeEnd() {
var newIndex = index;
var swipeThresold = slide.offsetWidth / 8;
if (tx < -swipeThresold) {
newIndex++;
} else if (tx > swipeThresold) {
newIndex--;
}
index = newIndex;
radios[index].checked = true;
}
Depending on the platform however, the last radio state change statement will be enough or not to trigger the associated CSS translation to the selected slide. Notably, it will work on iOS but not on Android. So to make sure it will work on all platforms, you’ll have to be more explicit:
function swipeEnd(e) {
// ...
if (tx < -swipeThresold) {
newIndex++;
currentTx -= slide.offsetWidth; // Required for explicit translation
} else if (tx > swipeThresold) {
newIndex--;
currentTx += slide.offsetWidth; // Required for explicit translation
}
if (setIndex(newIndex)) { // Returns if newIndex is >=0 and < 4
requestTranslateTo(currentTx); // Explicit translation
}
setTimeout(function() {
slider.style.transform = ''; // Remove inline style to allow translate using CSS dots...
}, 300); // ...at transition's end
}
However relying on the touchend
event to handle all cases (selection of another slide or cancel of slide attempt) is too risky as sometimes it will never fire: if the browser determines that the gesture has been cancelled in a way or another (for example if it concludes that it is rather a scroll attempt), it will raise a touchcancel
event instead. One simple way to avoid this is to tell the browser that it should not care about your touchmove
:
function swipeDrag(touchEvent) {
touchEvent.preventDefault();
// ...
}
Transitions
Up to the pure CSS version, animating slides through transition was simple:
.slider {
transition: transform 0.3s ease; // Animate translateX for 300 ms
}
However the implemention of a gesture complicates the case, because:
- translating to user touch position with CSS transitions actually slows down the gesture (i.e. triggers transitions between different positions during
touchMove
). - we want to keep the “simple” benefits of CSS transitions in two cases:
- when the user selects radio inputs/labels and so triggers translations + dot styles through CSS ;
- when the user releases his/her touch: instead of translating programmatically to the nearest slide, CSS can do it for us.
So, basically, we want to disable the CSS transitions during slide drag, then re-enable them during at touch end.
when we must not always enforce translations programmatically as they would override CSS ones:
var slider = document.getElementsByClassName('slider')[0];
function swipeStart(e) {
// ...
slider.style.transition = 'none'; // Overrides declared CSS transition during drag
}
function swipeEnd() {
slider.style.transition = 'transform 0.3s ease'; // Enable transition before going to selected slide
radios[index].checked = true; // Triggers CSS translation to the selected slide
slider.style.transform = ''; // Remove inline style to restore CSS translate using CSS dots
}
That’s it for the good parts :) Feel free to look at the final code, where you’ll find additional improvements, such as boundaries warnings, keyboard support or exit/restart capabilities.