Replicating Zara’s Website Design: Tips and Tricks for Developers (Part Two)
Inspired by Zara, built with Remix and GSAP for seamless, fluid animations. Also built using Sanity CMS, it ensures a smooth user experience while maintaining flexibility and customizability.
In the previous post, I introduced the foundational concept behind this project. Today, I’ll dive deeper and walk you through some of the trickier aspects of developing this website.
Let’s begin by setting up the parent carousel, which will be defined in the Carousels.tsx
file. The following features are required for the carousel's functionality:
- Touchpad Gesture Support: The carousel should respond to touchpad swipe gestures, enabling smooth horizontal navigation through the slides.
- Boundary Constraints: When the first slide is active, swiping left (or backward) should be restricted, ensuring that the user cannot scroll past it. Similarly, when the last slide is active, swiping right (or forward) should be disallowed, effectively locking the scroll boundaries at both ends.
- Automatic Snapping: While swiping horizontally, if a slide is dragged beyond 50% of its width, the carousel should automatically
snap
to the nearest slide position. This ensures a smooth user experience by preventing partial visibility of slides and offering precise alignment. - Controlled Drag Behavior: The carousel should not allow
drag free
scrolling, which permits users to drag the slides freely without snapping. We want precise control over the swipe behavior, so the carousel will only snap to predefined positions based on user interaction. (In case you are not clear on the meaning ofdrag free
, playing around with the Embla generator will give you an idea of what I mean.)
These four functionalities that I want can all be implemented with the help of Embla Carousel
and embla-carousel-wheel-gestures.
npm i embla-carousel embla-carousel-wheel-gestures
Here is the GIF demo to show what I want.
import React, { useState, useEffect, useCallback, useRef } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { useNestedEmblaCarousel } from './useNestedEmblaCarousel';
import NestedCarousel from './NestedCarousel';
import { useDotButton } from './EmblaCarouselDotButton';
import {
PrevButton,
NextButton,
usePrevNextButtons,
} from './EmblaCarouselArrowButtons';
export default function Carousels({ data }: any) {
const { carousels, _key, _type } = data;
const [rotation, setRotation] = useState(0);
const [rotateNum, setRotateNum] = useState(0);
const [currentInViewContainer, setCurrentInViewContainer] = useState(0);
const prevBtnRotation = () => {
setRotation((rotation) => rotation + 18);
setRotateNum(rotation);
};
const nextBtnRotation = () => {
setRotation((rotation) => rotation - 18);
setRotateNum(rotation);
};
const [viewportRef, embla] = useEmblaCarousel(
{
axis: 'x',
skipSnaps: false,
dragFree: false,
inViewThreshold: 1,
},
[WheelGesturesPlugin({ forceWheelAxis: 'x' })]
);
const onScroll = useCallback(() => {
if (!embla) return;
const engine = embla.internalEngine();
const {
limit,
target,
location,
offsetLocation,
scrollTo,
translate,
scrollBody,
} = engine;
let edge: number | null = null;
if (location.get() > limit.max) {
embla.scrollTo(0);
}
if (location.get() < limit.min) {
embla.scrollTo(embla.scrollSnapList().length - 1);
}
}, [embla]);
const inView = useCallback(() => {
if (!embla) return;
const inViews = embla?.slidesInView();
const snaps = embla?.scrollSnapList();
const nodes = embla?.slideNodes(); //dom elements array
const selected = embla?.selectedScrollSnap(); //The current slide index number, the one that in view.//
setCurrentInViewContainer(selected);
}, [embla]);
useEffect(() => {
if (!embla) return;
// embla.on('select', onSelect);
embla.on('scroll', onScroll);
embla.on('scroll', inView);
}, [embla, currentInViewContainer]);
const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton(embla);
const {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
} = usePrevNextButtons(embla);
return (
<>
<div className="embla">
<div className="embla__viewport" ref={viewportRef}>
<div className="embla__container">
{carousels.map(
(
s: {
menu:
| string
| number
| boolean
| React.ReactElement<
any,
string | React.JSXElementConstructor<any>
>
| Iterable<React.ReactNode>
| React.ReactPortal
| Iterable<React.ReactNode>
| null
| undefined;
carousels: any;
},
index: React.Key | null | undefined
) => {
return (
<NestedCarousel
//@ts-ignore
slides={s.carouselItems}
id={index}
currentInViewContainer={currentInViewContainer}
menu={s.menu}
/>
);
}
)}
</div>
</div>
<div className="embla__buttons">
<PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} />
<NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} />
</div>
</div>
</>
);
}
Let’s keep moving to the children's carousels, which will be defined in nestedCarousel.tsx
file. The following features are required for the children’s carousel's functionality:
- Touchpad Gesture Support: The carousel should respond to touchpad swipe gestures, enabling smooth vertical navigation through the slides within the parent carousel.
- Boundary Constraints: It must enforce boundary constraints, preventing scrolling past the first or last slide as previously before.
- Automatic Snapping: Automatic snapping should occur when a slide is dragged beyond 50% of its height, ensuring clean, aligned transitions.
- Controlled Drag Behavior: The carousel should not allow
drag free
scrolling as before. - Following slide stacks on top of the previous one: When swiping vertically, each subsequent slide should stack elegantly over the previous one, offering a visually cohesive experience as users interact with the carousel.
These four functionalities that I want can all be implemented with the help of GSAP
.
npm install gsap @gsap/react
Here is the GIF demo to show what I want.
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from 'react';
import imageUrlBuilder from '@sanity/image-url';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import clsx from 'clsx';
import { SanityImageAssetDocument } from '@sanity/client';
import gsap from 'gsap/dist/gsap';
import { useGSAP } from '@gsap/react';
import { ScrollTrigger } from 'gsap/dist/ScrollTrigger';
import { Observer } from 'gsap/dist/Observer';
import { Draggable } from 'gsap/dist/Draggable';
import { DotButton, useDotButton } from './EmblaCarouselDotButton';
gsap.registerPlugin(ScrollTrigger, Observer, Draggable);
const VariedTypeCarousel = ({ s, parallaxValues, index }: any) => {
switch (s._type) {
case 'image':
return (
<div className=" panel" key={index}>
<img
src={urlFor(s.asset._ref).url()}
className="w-full h-full object-cover img"
/>
</div>
);
case 'video':
return (
<div className="panel " key={index}>
<video
//@ts-ignore
autoPlay="autoplay"
muted
playsInline
loop
className="w-full h-full object-cover img"
>
<source src={`${s.id}`} type="video/mp4" />
</video>
</div>
);
default:
return null;
}
};
const NestedCarousel = ({
slides,
id,
embla,
currentInViewContainer,
menu,
}: any) => {
const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton(embla);
useGSAP(
() => {
let panels = window.document.querySelectorAll(
`#embla__slide__container__${currentInViewContainer} .img`
);
const container = window.document.querySelector(
`#embla__slide__container__${currentInViewContainer}`
);
let sections = document.querySelectorAll(
`#embla__slide__container__${currentInViewContainer} .section`
),
lastTap = 0,
outerWrappers = gsap.utils.toArray(
`#embla__slide__container__${currentInViewContainer} .wrapper-outer`
),
innerWrappers = gsap.utils.toArray(
`#embla__slide__container__${currentInViewContainer} .wrapper-inner`
),
currentIndex = -1, // Start at the first section
wrap = gsap.utils.wrap(0, sections.length),
animating = false;
gsap.set(outerWrappers, { yPercent: 100 });
gsap.set(innerWrappers, { yPercent: -100 });
gsap.set(sections, { autoAlpha: 0, zIndex: 0 });
gsap.set(sections[currentIndex], { autoAlpha: 1, zIndex: 1 });
function gotoSection(index: number, direction: number) {
if (
animating ||
index === currentIndex ||
index < 0 ||
index >= sections.length
)
return;
// Reset index to 0 if it exceeds bounds or goes negative
if (index >= sections.length || index < 0) {
index = 0;
}
animating = true;
let fromTop = direction === -1,
dFactor = fromTop ? -1 : 1,
tl = gsap.timeline({
defaults: { duration: 1.5, ease: 'power1.inOut' },
//@ts-ignore
onComplete: () => (animating = false),
});
if (currentIndex >= 0) {
gsap.set(sections[currentIndex], { zIndex: 0 });
tl.to(panels[currentIndex], { yPercent: 0 })
.set(sections[currentIndex], { autoAlpha: 0 });
}
gsap.set(sections[index], { autoAlpha: 1, zIndex: 1 });
//index is for the current one. currentIndex is for the previous one.//
if (index === 0) {
// Set the first slide to its original position
gsap.set([outerWrappers[index], innerWrappers[index]], {
yPercent: 0,
});
gsap.set(panels[index], { yPercent: 0 });
} else {
// Apply the GSAP animations for all other slides
tl.fromTo(
[outerWrappers[index], innerWrappers[index]],
{ yPercent: (i) => (i ? -100 * dFactor : 100 * dFactor) },
{ yPercent: 0 },
0
).fromTo(panels[index], { yPercent: dFactor }, { yPercent: 0 }, 0);
}
currentIndex = index;
}
function handleTap(event: any) {
let currentTime = new Date().getTime();
let tapLength = currentTime - lastTap;
if (tapLength < 500 && tapLength > 0) {
if (!animating && currentIndex < sections.length - 1) {
gotoSection(currentIndex + 1, 1);
}
}
lastTap = currentTime;
}
sections.forEach((section: any) => {
section.addEventListener('touchend', handleTap);
});
//The observer automatically falls into the current one.
Observer.create({
target: container,
type: 'wheel,touch, pointer',
wheelSpeed: -1,
onDown: () => !animating && gotoSection(currentIndex - 1, -1),
onUp: () => !animating && gotoSection(currentIndex + 1, 1),
tolerance: 10,
preventDefault: true,
});
gotoSection(0, 1);
},
{ dependencies: [currentInViewContainer] }
);
return (
<>
<div
id={`embla__slide__container__${id}`}
className="min-w-full embla__slide__container"
>
{slides.map((s: any, index: any) => {
return (
<section className="section">
<div className="wrapper-outer">
<div className="wrapper-inner">
<div>
<div className="flex justify-center items-center relative h-screen w-screen">
<h1 className="text-3xl absolute z-50 text-white font-mono font-normal">
{menu}
</h1>
</div>
</div>
<VariedTypeCarousel s={s} index={index} key={index} />
</div>
</div>
</section>
);
})}
<div className="embla__dots">
{scrollSnaps.map((_, index) => (
<DotButton
key={index}
onClick={() => onDotButtonClick(index)}
className={'embla__dot'.concat(
index === selectedIndex ? ' embla__dot--selected' : ''
)}
/>
))}
</div>
</div>
</>
);
};
export default NestedCarousel;
export const projectId = '';
export const dataset = '';
export const apiVersion = '';
const builder = imageUrlBuilder({ projectId, dataset });
export function urlFor(source: SanityImageAssetDocument) {
return builder.image(source);
}
The trickiest part I initially didn’t grasp was identifying the container currently in view. You need to inform GSAP which specific container you want to apply the vertical scroll to.
const container = window.document.querySelector(
`#embla__slide__container__${currentInViewContainer}`
);
It took me a while to figure out how to access the currentInViewContainer
. You really need to have a basic understanding of Embla's internal engine before diving into it, but that eventually did the trick.
const [currentInViewContainer, setCurrentInViewContainer] = useState(0);
//The current slide index number, the one that in view.//
const selected = embla?.selectedScrollSnap();
setCurrentInViewContainer(selected);
That’s it. This is a lot of fun yet very challenging side projects. In the next final post, I am going to show you how to store the image and video in the Amazon Cloudfront and S3 Bucket. I will walk you through step by step.