Build a Slider With DOM Events

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!