I happened across an AWESOME video tutorial by Hyperplexed, who recreated the incredible image sliding-tracking effect designed by Camille Mormal.
So I followed through the entire video, looked up some pet images on Unsplash, and attempted to replicate Hyperplexed’s rendition of the image slider on my own blog.
And the image slider was indeed buttery smooth.
You’re both amazing, Hyperplexed and Camille.
Unfortunately, the animation effects can only be viewed on desktop. The best I can do is a block of horizontally scrollable images on mobile.
Sorry about that.
But hey, at least we all get to see some cute pets!
Update 1: After a sleepless night of twisting and turning on my bed, I decided to spice up the mobile view with some parallax effects.
Update 2: I also added in some code to make the slider a little bit more accessible by enabling tab-based navigation using the keyboard on desktop.
Update 3: Finally, I added in some fallback code so the image slider can still be used even if JavaScript is disabled.
Update 4: I also added a small quality of life improvement to help center the image slider automatically on desktop.
The image slider in action:
Author’s note: If the above image slider fails to work on your browser for whatever reason, please do get in touch with me. I spent three whole days (and nights!) working on this and really want to know how I can make it even better.
Unfortunately, there isn’t much I can do if you’re using an outdated browser.
Obviously, I had to make a number of adjustments (you need to see Hyperplexed’s video tutorial first for this to make sense):
- Hyperplexed set himself a challenge to use as little HTML, CSS, and JS code as possible, so a lot of detail got left out for brevity. His implementation is a half-completed proof-of-concept prototype that cannot be used on live websites.
- Hyperplexed’s approach is not mobile responsive, and I had difficulty getting things to actually work on mobile, so I took the easy way out and enabled the sliding-tracking effects only on desktop.
My apologies to mobile users. I have attempted to redeem myself with some parallax scrolling for mobile. - I also added links back to the images on Unsplash to provide credit for the photographers. Unfortunately, the links caused some strange interactions on mobile – more reason to disable the behaviour on touch screens.
- I didn’t like the click-and-drag approach, so I simply set the slider to respond to mouse movements. It feels strange to fight the default browser behavior, and getting click-and-drag to work smoothly is very difficult. I honestly think tracking and responding to mouse movements works out better, so less is more here.
- And since I didn’t want my image slider to take up the entire page (it needs to coexist with my blog post!), I had to make adjustments to the JS calculations.
For those who want to try out my modifications, here’s a copy of the code. All you need to do is slap them into a blank html page and experiment away!
<style>
body{
overflow-x: hidden;
}
.cm-slider__wrapper{
overflow-x: scroll;
}
.cm-slider{
display: flex;
gap: 5vmin;
user-select: none;
}
.cm-slider__image{
min-width: 45vmin;
max-width: 45vmin;
height: 65vmin;
object-fit: cover;
object-position: center center;
}
@media (hover: hover){
.cm-slider__wrapper{
height: 100vh;
position: relative;
overflow: hidden;
background-color: black;
width: 80vw;
max-width: 80vw !important;
margin-left: calc(-40vw + 50%);
margin-right: calc(-40vw + 50%);
}
@media (max-width: 1120px){
.cm-slider__wrapper{
width: 100vw !important;
max-width: 100vw !important;
margin-left: calc(-50vw + 50%) !important;
margin-right: calc(-50vw + 50%) !important;
}
}
.cm-slider{
position: absolute;
top: 50%;
left: 50%;
transform: translate(0%,-50%);
}
.cm-slider__image{
object-position: 0% center;
}
}
@media (hover: none){
.cm-slider__image{
min-width: 65vmin;
max-width: 65vmin;
}
.cm-slider > :first-child{
margin-left: 5vmin;
}
/* Some display:flex and ::after voodoo to get a margin-right in overflow-x:scroll https://stackoverflow.com/a/38997047 */
.cm-slider > :last-child{
display: flex;
}
.cm-slider > :last-child::after{
content: "";
flex: 0 0 5vmin;
}
.cm-slider__wrapper{
overflow-x: scroll;
padding: 5vmin 0;
width: 100vw !important;
max-width: 100vw !important;
margin-left: calc(-50vw + 50%) !important;
margin-right: calc(-50vw + 50%) !important;
}
}
</style>
<div class="cm-slider__wrapper">
<div class="cm-slider">
<a class="cm-slider__link" data-slide-index="0" target="_blank" href="https://unsplash.com/photos/ouo1hbizWwo/" title="Photo by Andrew S on Unsplash" rel="noopener">
<img class="cm-slider__image" alt="Photo by Andrew S on Unsplash" src="https://unsplash.com/photos/ouo1hbizWwo/download?&w=1280">
</a>
<a class="cm-slider__link" data-slide-index="1" target="_blank" href="https://unsplash.com/photos/ISg37AI2A-s/" title="Photo by Eric Ward on Unsplash" rel="noopener">
<img class="cm-slider__image" alt="Photo by Eric Ward on Unsplash" src="https://unsplash.com/photos/ISg37AI2A-s/download?&w=1280">
</a>
<a class="cm-slider__link" data-slide-index="2" target="_blank" href="https://unsplash.com/photos/6aY_0S-epZQ/" title="Photo by Jack Catalano on Unsplash" rel="noopener">
<img class="cm-slider__image" alt="Photo by Jack Catalano on Unsplash" src="https://unsplash.com/photos/6aY_0S-epZQ/download?&w=1280">
</a>
<a class="cm-slider__link" data-slide-index="3" target="_blank" href="https://unsplash.com/photos/9gz3wfHr65U/" title="Photo by Krista Mangulsone on Unsplash" rel="noopener">
<img class="cm-slider__image" alt="Photo by Krista Mangulsone on Unsplash" src="https://unsplash.com/photos/9gz3wfHr65U/download?&w=1280">
</a>
<a class="cm-slider__link" data-slide-index="4" target="_blank" href="https://unsplash.com/photos/v3-zcCWMjgM/" title="Photo by James Barker on Unsplash" rel="noopener">
<img class="cm-slider__image" alt="Photo by James Barker on Unsplash" src="https://unsplash.com/photos/v3-zcCWMjgM/download?&w=1280">
</a>
<a class="cm-slider__link" data-slide-index="5" target="_blank" href="https://unsplash.com/photos/9UUoGaaHtNE/" title="Photo by Ludemeula Fernandes on Unsplash" rel="noopener">
<img class="cm-slider__image" alt="Photo by Ludemeula Fernandes on Unsplash" src="https://unsplash.com/photos/9UUoGaaHtNE/download?&w=1280">
</a>
<a class="cm-slider__link" data-slide-index="6" target="_blank" href="https://unsplash.com/photos/MUcxe_wDurE/" title="Photo by Bonnie Kittle on Unsplash" rel="noopener">
<img class="cm-slider__image" alt="Photo by Bonnie Kittle on Unsplash" src="https://unsplash.com/photos/MUcxe_wDurE/download?&w=1280">
</a>
</div>
</div>
<script>
<!--
const cmSliderWrapper = document.querySelector(".cm-slider__wrapper");
const getWindowWidth = () => window.innerWidth || document.documentElement.clientWidth;
const getWindowHeight = () => window.innerHeight || document.documentElement.clientHeight;
const getWidthAdjustment = () => (getWindowWidth() - cmSliderWrapper.offsetWidth) / 2
//const isTouchScreenOnly = () => ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
const isTouchScreenOnly = () => !window.matchMedia("(hover: hover)").matches;
cmSliderWrapper.onmousemove = e => {
if(isTouchScreenOnly()) return;
let percentage = ((e.clientX - getWidthAdjustment()) / cmSliderWrapper.offsetWidth) * 100;
percentage = percentage > 100 ? 100 : percentage < 0 ? 0 : percentage;
let cmSlider = document.querySelector(".cm-slider");
//cmSliderWrapper.dataset.percentage = percentage;
//cmSlider.style.transform = `translate(${-percentage}%, -50%)`;
let animateDuration = 800;
if(typeof userTabbed !== "undefined" && userTabbed){
animateDuration = 300;
userTabbed = false;
}
cmSlider.animate({
transform: `translate(${-percentage}%, -50%)`
},{
duration:animateDuration, fill:"forwards"
});
for(const image of cmSlider.getElementsByClassName("cm-slider__image")){
//image.style.objectPosition = `${percentage}% center`;
image.animate({
objectPosition: `${percentage}% center`
},{
duration:animateDuration, fill:"forwards"
});
}
if(typeof nudgeSliderScrollPosition !== "undefined" && nudgeSliderScrollPosition) nudgeSliderScrollPosition();
}
//scatteringclouds.com--></script>
Update 1: I couldn’t sleep and really think the mobile experience should get a bit of “Oomph!” as well. At the very least I could add some parallax scrolling to the mobile slider. So here’s what my sleep deprived mind threw together to make it happen.
<script>
<!--
const isInViewport = rect => (
rect.top >= -rect.height &&
rect.left >= -rect.width &&
rect.bottom <= getWindowHeight() + rect.height &&
rect.right <= getWindowWidth() + rect.width
);
cmSliderWrapper.addEventListener("scroll", () => {
if(!isTouchScreenOnly()) return;
let cmSlider = document.querySelector(".cm-slider");
for(const image of cmSlider.getElementsByClassName("cm-slider__image")){
let rect = image.getBoundingClientRect();
if( isInViewport(rect) ){
let percentage = ((rect.x + rect.width) / (getWindowWidth() + rect.width)) * 100;
image.style.objectPosition = `${percentage}% center`;
}
}
});
cmSliderWrapper.dispatchEvent(new Event("scroll")); // fire event manually to trigger objectPosition calculation for images
let wasTouchScreenOnly = isTouchScreenOnly(); // this code is used mainly to reset animations when turning on/off device emulation with Chrome DevTools
window.addEventListener("resize", () => {
if(wasTouchScreenOnly && isTouchScreenOnly()) return; // do nothing if resizing device screen on mobile
wasTouchScreenOnly = isTouchScreenOnly(); // update current device state
if(!isTouchScreenOnly()) return; // do nothing if resizing screen on non-mobile devices
// replace cmSlider with its clone when transitioning from desktop to mobile to reset any animations applied previously
let cmSlider = document.querySelector(".cm-slider");
cmSlider.parentNode.replaceChild(cmSlider.cloneNode(true), cmSlider);
});
//scatteringclouds.com--></script>
Update 2.1: I added a new JS variable called userTabbed to keep track of whether the user is navigating the image slider using mouse movement or tabbing. When the user is tabbing, the slider will animate much quicker, which alleviates the jankiness. Turns out, the browser is not too happy when a large number of long animations are fired off consecutively in short succession.
Update 2.2: I just realized I could improve the tabbing capabilities on mobile as well! All I needed was to apply scrollIntoView onto the images!
Update 2.3: Found out that onFocusChange() fires for both tab presses and link clicks, which makes the slider extremely janky when you try to click on any of the image links. So I added a new variable called userMouseDown and three additional event listeners to determine whether the onFocusChange() function should return.
<script>
<!--
let prevActiveElement = document.activeElement;
let userTabbed = false; // we use this to reduce animation time on the cmSlider to prevent jankiness on desktop animation.
let userMouseDown = 0; // we use this to track when the user clicks and releases the mouse.
const onFocusChange = () => {
//if(isTouchScreenOnly()) return; // this code deals with tabbed navigation using a keyboard on desktop view. Ignore mobile devices.
if(userMouseDown > 0) return; // ignore focus event if mouse has been clicked
let currentActiveElement = document.activeElement;
if(prevActiveElement === currentActiveElement) return; // ignore if we have already done this, since we are listening to both focus and blur events
prevActiveElement = currentActiveElement; // update previous active element
if(!currentActiveElement.classList.contains("cm-slider__link")) return;
if(isTouchScreenOnly()){
currentActiveElement.scrollIntoView({
behavior: "auto",
block: "center",
inline: "center"
});
return;
}
userTabbed = true; // this variable is used on desktop only
const siblingCount = currentActiveElement.parentElement.children.length;
//const currentIndex = Array.from(currentActiveElement.parentElement.children).indexOf(currentActiveElement);
const currentIndex = parseInt(currentActiveElement.dataset.slideIndex);
const sliderWidth = cmSliderWrapper.offsetWidth;
const calcX = getWidthAdjustment() + ((currentIndex + 0.5)*(sliderWidth/siblingCount));
const calcY = getWindowHeight()/2;
//console.log(`x:${calcX} y:${calcY}`);
cmSliderWrapper.scrollIntoView({
behavior: "auto",
block: "center",
inline: "center"
});
cmSliderWrapper.dispatchEvent(new MouseEvent(
"mousemove",
{
"view": window,
"bubbles": true,
"clientX": calcX,
"clientY": calcY,
}
));
}
window.addEventListener("focus", onFocusChange, true);
window.addEventListener("blur", onFocusChange, true);
const onMouseDown =() => {
userMouseDown++;
}
const onMouseUp = () => {
userMouseDown--;
}
const onKeyDown = (e) => {
if(e.keyCode === 9) userMouseDown = 0; // if user pressed tab, reset userMouseDown to 0
}
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("keydown", onKeyDown);
//scatteringclouds.com--></script>
Update 3: And finally, some fallback CSS styling for when JavaScript is disabled on desktop. The slider works by default on mobile without the need for any JavaScript code – you would only lose the parallax effect.
<noscript>
<style>
@media (hover: hover){
.cm-slider{
position: static;
transform: none;
height: 100%;
align-items: center;
}
.cm-slider > :first-child{
margin-left: 5vmin;
}
.cm-slider > :last-child{
display: flex;
}
.cm-slider > :last-child::after{
content:"";
flex: 0 0 5vmin;
}
.cm-slider__wrapper{
overflow-x: scroll;
}
.cm-slider__image{
object-position: center;
}
}
</style>
</noscript>
Update 4: Here’s a quality of life change I concocted to center the image slider in the middle of the browser window on desktop automatically…
Using small but frequent nudges. Like REEEEAAAAAALLY frequent nudges.
It’s a clever little piece of code that I feel really proud of 😁
Update 4.1: I also experimented with keeping track of scroll direction to improve nudging, but decided that the experience feels too inconsistent to be worthwhile.
Again, less is probably more here.
<script>
<!--
const getScrollPosition = () => window.pageYOffset || document.documentElement.scrollTop;
/*let prevScrollPosition = getScrollPosition();
let scrollDirection = 0; // set to 1 for scrolling down, -1 for scrolling up
const updateScrollDirection = () => {
let currentScrollPosition = getScrollPosition();
scrollDirection = currentScrollPosition > prevScrollPosition ? 1 : -1;
prevScrollPosition = currentScrollPosition;
}
window.addEventListener("scroll", updateScrollDirection);//*/
const nudgeSliderScrollPosition = () => {
if(isTouchScreenOnly()) return;
let rect = cmSliderWrapper.getBoundingClientRect();
if(!isInViewport(rect)) return;
const percentageInViewport = 0.33;
const nudgeRange = rect.height * percentageInViewport;
if(Math.abs(rect.top) > nudgeRange) return;
//if(scrollDirection === 1 && rect.top + (nudgeRange/2) < 0) return; // don't nudge if we are scrolling downwards to leave the slider
//if(scrollDirection === -1 && rect.bottom - (nudgeRange/2) > rect.height) return; // don't nudge if we are scrolling upwards to leave the slider
let distance = rect.top / 10;
const currentScrollPosition = getScrollPosition();
window.scrollBy({
top: distance,
behavior: "instant"
});
if(currentScrollPosition === getScrollPosition()){
cmSliderWrapper.scrollIntoView({
behavior: "instant",
block: "center",
inline: "center"
});
}
}
//scatteringclouds.com--></script>
Phew! All that effort for a silly little image slider…
I’m beginning to understand why Hyperplexed decided to put such strict limitations on the amount of code he used to recreate Camille Mormal’s design.