# Build a Slider With DOM Events

## 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1712951171541/7ed830ad-874a-4bf1-813e-690af132b50a.png align="center")

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](https://nextjs.org/docs/app/building-your-application/routing/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.

```typescript
// @/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.

```typescript
// @/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**

```typescript
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](https://developer.mozilla.org/en-US/docs/Web/API/Document) 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](https://developer.mozilla.org/en-US/docs/Web/API/Element) to get the the width of our `featureTrack` element and our individual `featureEl` element.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
// @/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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1713229157249/40b6bdc5-3801-4758-8b3d-9637172cf659.gif align="center")

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!
