Build a Slider With DOM Events
A guide to creating type-safe, zero dependency sliders in the Next.js App Router
Intro
Sliders (or "carousels") are a great UI option for displaying large blocks of content in a way that doesn't clutter the UX. They come in all shapes and sizes, but ultimately they share a common goal: allow the user to interact with content beyond the constraints of their screen dimensions.
Objective
They say a picture is worth a thousand words. Instead of trying to explain what our objective is, I created a wireframe to illustrate what we're going to be building today.
This "carousel" style layout is great for displaying features on a landing page. It features a main content block (the "slide") with an interactive progress bar and navigational buttons that can be clicked to go directly to a specific slide.
It's also going to feature dynamic styling with TailwindCSS that updates based on the state of the slider.
Let's jump in.
Implementation
To accomplish this goal, we don't need any external libraries or fancy plugins. We can rely entirely on good ol' DOM events.
Since we're using the Next.js 14 App Router for this project, we have to consider where our code is going to be rendered and plan accordingly.
My preferred method is to render page components on the server and then import client components only when interactivity or browser APIs are needed. Leveraging client components only when they are needed will make your app more performant, more secure, and more efficient.
Server Rendered Page
We're going to be adding the slider to the home page of our website, which means the page.tsx
file is going to located in the root of the @/app
directory. If you're using route groups, then it could be inside of an organization folder like @/app/(marketing)
. That's what I'm going to be doing for this project, so that I can keep the marketing pages separate from the dashboard pages.
// @/app/(marketing)/page.tsx
import { FeatureCarousel } from "@/components/marketing/feature-carousel";
const HomePage = () => {
return (
<>
{/* ...other page content */}
<FeatureCarousel />
{/* ...other page content */}
</>
)
};
export default HomePage;
Client Components
Sliders are very much dependent on client-side interactivity, so even though our page is being rendered server-side, we are going to have to render at least some of our code client-side.
For this slider, we are only going to need two client components. One that will act as a "container" to manage state and then a child component for each feature in the slider.
Slider Component
Here is the code for the FeatureCarousel
which is the "container" component. I'll elaborate more on this below.
// @/components/marketing/feature-carousel.tsx
"use client"
import React, { useEffect, useState } from "react"
import { useDebounceValue } from "usehooks-ts"
import { cn } from "@/lib/utils"
import { Feature } from "./feature"
export const FeatureCarousel = () => {
const [activeFeature, setActiveFeature] = useDebounceValue(1, 50)
const [scrollProgress, setScrollProgress] = useState(0)
let featureTrack: HTMLElement | null = null;
let featureTrackScrollWidth: number = 0;
let featureEl: HTMLElement | null = null;
let featureElClientWidth: number = 0;
if (typeof document !== 'undefined') {
featureTrack = document.getElementById("FeatureCarouselTrack");
featureTrackScrollWidth = featureTrack?.scrollWidth as number;
featureEl = document.getElementById("Feature");
featureElClientWidth = featureEl?.clientWidth as number;
}
const onTrackScroll = (e: React.UIEvent<HTMLElement>) => {
let progress = Number(( e.currentTarget.scrollLeft / e.currentTarget.scrollWidth ).toPrecision(2)) * 100;
setScrollProgress(progress);
}
useEffect(() => {
switch (true) {
case scrollProgress < 25:
setActiveFeature(1);
break;
case scrollProgress >= 25 && scrollProgress < 50:
setActiveFeature(2);
break;
case scrollProgress >= 50 && scrollProgress < 75:
setActiveFeature(3);
break;
case scrollProgress >= 75:
setActiveFeature(4);
break;
default:
setActiveFeature(1);
break;
}
}, [scrollProgress])
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
console.log(e, '<== e clicked')
let id = parseInt(e.currentTarget.dataset.id as string);
let n: number = 0;
n = ( id - 1 ) * featureElClientWidth;
featureTrack?.scrollTo( { left: n , top: 0, behavior: 'smooth' } )
}
return (
<div
id="FeatureCarouselContainer"
className="max-w-screen-xl mx-auto px-4 space-y-8"
>
<div
id="FeatureCarouselTrack"
onScroll={onTrackScroll}
className="flex flex-row snap-x snap-mandatory overflow-x-scroll [scrollbar-width:none] overscroll-x-contain py-8"
>
{Array.from({ length: 4 }).map((_, i) => (
<Feature
key={i + 1}
number={i + 1}
title={`Feature ${i + 1}`}
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
/>
))}
</div>
<div
className="relative grid grid-cols-4 w-full text-center border-collapse overflow-clip"
>
<div
className="absolute top-0 w-1/4 h-[1px] bg-primary ease-linear"
style={{ left: `${scrollProgress}%` }}
/>
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i + 1}
data-id={i + 1}
role="button"
className={cn(
"border-t border-dashed border-muted py-8 px-2 text-muted-foreground",
activeFeature === (i + 1) && "text-primary"
)}
onClick={(e) => handleClick(e)}
>
Feature {i + 1}
</div>
))}
</div>
</div>
)
}
This component has some pretty cool stuff going on. Let's break it down.
State Variables
We're keeping track of which feature is currently "active" and determining this based on the client's scroll progress.
activeFeature
is debounced with a 50ms delay to help mitigate any potential "jitteriness" when the active feature changes.scrollProgress
is being stored as a percentage of the total container width which is calculated with the values we receive from the DOM
Interacting with the DOM
if (typeof document !== 'undefined') {
featureTrack = document.getElementById("FeatureCarouselTrack");
featureTrackScrollWidth = featureTrack?.scrollWidth as number;
featureEl = document.getElementById("Feature");
featureElClientWidth = featureEl?.clientWidth as number;
}
Here we're using the getElementById()
method of the Web API Document interface to get information about the featureTrack
and featureEl
elements from the rendered HTML.
Then once we've stored those elements, we use the scrollWidth
and clientWidth
properties of the Web API Element interface to get the the width of our featureTrack
element and our individual featureEl
element.
const onTrackScroll = (e: React.UIEvent<HTMLElement>) => {
let progress = Number(( e.currentTarget.scrollLeft / e.currentTarget.scrollWidth ).toPrecision(2)) * 100;
setScrollProgress(progress);
}
In this code, we're calculating where we're at in the feature track. When we're at the beginning of the feature track (all the way to the left), our scrollLeft
value is 0 so we know our scroll progress is 0; however, as we scroll to the right, the scrollLeft
value becomes larger and increases our scroll progress linearly.
useEffect(() => {
switch (true) {
case scrollProgress < 25:
setActiveFeature(1);
break;
case scrollProgress >= 25 && scrollProgress < 50:
setActiveFeature(2);
break;
case scrollProgress >= 50 && scrollProgress < 75:
setActiveFeature(3);
break;
case scrollProgress >= 75:
setActiveFeature(4);
break;
default:
setActiveFeature(1);
break;
}
}, [scrollProgress])
Next up, we have a useEffect
hook with a scrollProgress
dependency. Whenever the scroll progress value changes, it is passed to a switch statement which sets the current active feature.
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
console.log(e, '<== e clicked')
let id = parseInt(e.currentTarget.dataset.id as string);
let n: number = 0;
n = ( id - 1 ) * featureElClientWidth;
featureTrack?.scrollTo( { left: n , top: 0, behavior: 'smooth' } )
}
Lastly, our handleClick
function allows the user to skip directly to a specific feature without scrolling. From the MouseEvent, we extract the data-id we assigned to each "button" and then use that to calculate where we need to scroll within the feature track.
Content Component
The actual feature component is pretty basic, but I'll include the code below.
// @/components/marketing/feature.tsx
import Image from "next/image";
export const Feature = ({
number,
title,
description
}: {
number: number;
title: string;
description: string;
}) => {
return (
<div id="Feature" data-key={number} className="min-w-full h-fit snap-start grid grid-cols-1 items-center gap-16 px-4 lg:px-16">
<div className="grid lg:grid-cols-2 grid-cols-1 items-center gap-8">
<div className="">
<div className="text-6xl">{title}</div>
</div>
<div className="">
{description}
</div>
</div>
<div className="grid lg:grid-cols-2 grid-cols-1 items-center gap-8">
<div className="grid grid-cols-1 items-start gap-8">
<div className="space-y-4">
<div className="font-semibold lg:pr-32">Lorem ipsum.</div>
<div className="text-muted-foreground lg:pr-32 ">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</div>
</div>
<div className="space-y-4">
<div className="font-semibold lg:pr-32">Lorem ipsum.</div>
<div className="text-muted-foreground lg:pr-32">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</div>
</div>
</div>
<div>
<Image
src="/placeholder-browser.svg"
alt="Browser"
width={600}
height={400}
/>
</div>
</div>
</div>
)
}
Conclusion
Here is the final product:
The DOM is a treasure trove of valuable information when you want to build interactivity into your components. As you can see, once you know where to look it really doesn't take a whole lot of effort to build your own components from scratch!