Customising Hooper Carousel.
Using Hooper carousel to satisfy a design requiremen with 2 synced sliders and customised transitions.
Introduction
This will not be a post about why carousels are bad, or how to create a carousel from scratch, or even how to implement a specific carousel.
This will be more towards how I solved a design problem using a specific carousel and tweaks I made to get it where I needed.
The design called for a specific layout, with a top area for a speaker image, and the lower area showing speaker details. On mobile the image is full width, and the current slide details takes up 95% of the center, with a tiny amount of the previous and next slide details being visible to the sides. On larger screens this increases to one-third visibility for each details. So after a few ideas it really needed to be a carousel - even though I don't like them.
Below is a slighty changed variation of what was built for the project:
So even though I have an opinion that carousels / sliders are pretty bad for any sort of conversion the design was pretty clear. After some research I settled on using hooper which sells itself as:
A customizable accessible carousel slider optimized for Vue
Well it is very customisable (although I ran into a serious bug which caused no end of pain) and indeed getting the 2 sliders to sync was as easy as using the grouping option.
<hooper ref="upper" group="group1"> ... </hooper>
<hooper ref="lower" group="group1"> ... </hooper>
Then all we have to do is populate the carousel with some data and images:
After that I imported the base styles for Hooper, (In the project I used TailwindCSS combined with cuSTOM CSS in a SCSS partial, but the embedded example uses the style section of the vue component).
First time
Initially I tried to use the built in classes to target the features, and add apply in order to add the fading, shadow and transforms to the active class. This initially worked but then the cliennt wanted the slider to rotate infintely - whenn you reach the last item you should move to the first item again in an infinite loop.
After enabling the setting in Hooper, the styles broke. It seems that there was a bug inn calculating which slide was the current slide when infinite mode was enabled.
(post here)[https://github.com/baianat/hooper/issues/158]
so using the built in event @slide
which is an event which Emits after sliding occur
we can for example check a few things and manually set which is the active slide for the current Hooper instance.
<hooper
ref="upper"
group="group1"
:settings="hooperSettingsUpper"
class="px-8 upper md:px-0 mt-18"
@slide="updateUpperHooper"
>
...
</hooper>
methods: {
updateLowerHooper(slide) {
if (slide.currentSlide === -1)
this.current_slide_lower = this.items.length - 1;
else if (slide.currentSlide === this.items.length)
this.current_slide_lower = 0;
else this.current_slide_lower = this.$refs.lower.currentSlide;
},
updateUpperHooper(slide) {
if (slide.currentSlide === -1)
this.current_slide_upper = this.items.length - 1;
else if (slide.currentSlide === this.items.length)
this.current_slide_upper = 0;
else this.current_slide_upper = this.$refs.lower.currentSlide;
},
},
Then we can use some lovely vue class binding dependent on if the current slide matches the current looped items index. Here I add TailwindCSS classes to move the element up and add a shadow as well.
<slide v-for="(item, index) in items" :key="index" :index="index">
<article
class="p-4 mt-4 bg-white"
:class="
current_slide_lower === index
? 'transform -translate-y-16 shadow-lg'
: ''
"
>
<div class="text-2xl font-semibold">{{ item.name }}</div>
<div class="mt-8 text-lg">{{ item.content }}</div>
</article>
</slide>
Ideally it would be good to use the built in methods that keep track of the current slide. But as there was a bug a workaround was required.
Update: I was searching docs and issues on github and found a different solution that uses less code and does away with the if statements.
methods: {
updateUpperHooper(slide) {
const slides = this.items.length;
this.current_slide_upper = (slide.currentSlide + slides) % slides;
},
updateLowerHooper(slide) {
const slides = this.items.length;
this.current_slide_lower = (slide.currentSlide + slides) % slides;
},
},
final code:
<template>
<div>
<hooper
ref="upper"
group="group1"
:settings="hooperSettingsUpper"
class="px-8 upper md:px-0 mt-18 focus:outline-none"
@slide="updateUpperHooper"
>
<slide
v-for="(item, index) in items"
:key="index"
:index="index"
class="slide"
>
<article class="">
<div
class="relative flex items-center justify-center h-96 bg-gray-50"
>
<img
:src="item.img"
alt=""
class="block text-center border-2 border-transparent pointer-events-none"
:class="
current_slide_lower === index ? ' scale-105' : ' scale-100 '
"
/>
</div>
</article>
</slide>
<hooper-navigation slot="hooper-addons">
<svg slot="hooper-prev" class="w-6 h-6" viewBox="0 0 19.9 44">
<path
d="M19.9,44H16.7c-.1,0-.3,0-.3-.1L.4,22.5,0,22l1.5-2L16.3.2l.4-.2h3.2V.3L3.6,21.7c-.2.4-.2.3,0,.6L19.8,43.8Z"
/>
</svg>
<svg slot="hooper-next" class="w-6 h-6" viewBox="0 0 19.9 44">
<path
d="M0,0H3.3c.1,0,.2,0,.3.2l16,21.3.3.5-1.5,2.1L3.6,43.8c-.1.2-.2.2-.4.2H0l.2-.2L16.3,22.3c.2-.3.2-.2,0-.6C10.9,14.6,5.6,7.4.2.2Z"
/>
</svg>
</hooper-navigation>
</hooper>
<hooper
ref="lower"
group="group1"
:settings="hooperSettingsLower"
class="mb-12 lower focus:outline-none"
@slide="updateLowerHooper"
>
<slide v-for="(item, index) in items" :key="index" :index="index">
<article
class="p-4 mt-4 bg-white"
:class="
current_slide_lower === index
? 'transform -translate-y-16 shadow-lg'
: ''
"
>
<div class="text-2xl font-semibold">{{ item.name }}</div>
<div class="mt-8 text-lg">{{ item.content }}</div>
</article>
</slide>
<hooper-pagination slot="hooper-addons"></hooper-pagination>
</hooper>
</div>
</template>
<script>
import CloseIcon from "@/assets/svg/close.svg";
import {
Hooper,
Slide,
Pagination as HooperPagination,
Navigation as HooperNavigation,
} from "hooper";
import "hooper/dist/hooper.css";
export default {
components: {
CloseIcon,
Hooper,
Slide,
HooperPagination,
HooperNavigation,
},
data() {
return {
currentIndex: 0,
hooperSettingsUpper: {
mouseDrag: true,
touchDrag: true,
wheelControl: false,
shortDrag: true,
transition: 500,
infiniteScroll: true,
centerMode: true,
autoPlay: false,
config: { margin: "0" },
itemsToShow: 1,
},
hooperSettingsLower: {
transition: 200,
wheelControl: false,
shortDrag: false,
infiniteScroll: true,
centerMode: true,
autoPlay: false,
config: { margin: "0 20px" },
breakpoints: {
1200: {
itemsToShow: 3,
},
768: {
itemsToShow: 3,
mouseDrag: false,
touchDrag: false,
shortDrag: false,
},
0: {
itemsToShow: 1.2,
mouseDrag: true,
touchDrag: true,
shortDrag: true,
},
},
},
current_slide_lower: 0,
current_slide_upper: 0,
items: [
{
name: "Green",
img: require("@/assets/images/imac/green.jpg"),
content: "This is iMac in Green",
},
{
name: "Orange",
img: require("@/assets/images/imac/orange.jpg"),
content: "This is iMac in Orange",
},
{
name: "Purple",
img: require("@/assets/images/imac/purple.jpg"),
content: "This is iMac in Purple",
},
{
name: "Red",
img: require("@/assets/images/imac/red.jpg"),
content: "This is iMac in Red",
},
],
};
},
methods: {
updateUpperHooper(slide) {
const slides = this.items.length;
this.current_slide_upper = (slide.currentSlide + slides) % slides;
},
updateLowerHooper(slide) {
const slides = this.items.length;
this.current_slide_lower = (slide.currentSlide + slides) % slides;
},
},
};
</script>
<style>
.prose {
ul > li {
padding: 0;
}
.hooper li::before {
list-style-type: none;
content: none;
}
}
.hooper {
height: auto;
.hooper-track {
@apply leading-none;
}
.hooper-slide {
@apply leading-snug;
}
.hooper-navigation {
@apply h-full opacity-0 md:opacity-100;
button {
@apply h-full md:h-auto;
}
}
.hooper-next,
.hooper-prev {
@apply bg-white outline-none focus-visible:ring;
}
&.relative-pagination .hooper-pagination {
@apply relative bottom-auto right-auto transform-none p-0 items-center justify-center py-4 pb-6 w-full;
}
&.light-pagination .hooper-indicator {
@apply bg-white;
}
.hooper-indicators {
li + li {
@apply ml-2;
}
}
.hooper-indicator {
@apply outline-none focus:ring;
background-color: #323639;
height: 10px;
width: 10px;
border-radius: 100%;
@media screen and (min-width: 768px) {
height: 10px;
width: 10px;
}
&.is-active {
@apply bg-orange-500;
}
}
}
.hooper.upper {
/* margin-bottom: -60px; */
.hooper-slide {
img {
transition: all 500ms ease-in-out;
}
}
}
.hooper.lower {
@apply transform -translate-y-24;
.hooper-track {
margin-top: 12px;
margin-bottom: 12px;
/* padding-top: 50px; */
@apply pt-20;
}
article {
transition: all 200ms linear;
}
.hooper-slide {
@apply px-3;
}
}
</style>
methods: {
updateUpperHooper(slide) {
const slides = this.items.length;
this.current_slide_upper = (slide.currentSlide + slides) % slides;
},
updateLowerHooper(slide) {
const slides = this.items.length;
this.current_slide_lower = (slide.currentSlide + slides) % slides;
},
},