Build a Vanilla JS Slider From Scratch — Plus a Reusable React Carousel Component

Build a fully responsive vanilla JavaScript slider and carousel — no jQuery, no SwiperJS, zero dependencies. We cover the complete HTML/CSS/JS implementation with swipe support, keyboard navigation, autoplay, and dot indicators. Then we go further with a clean reusable React slider component built with hooks.

📋 Table of Contents

  1. Why Build a Slider Without jQuery?
  2. What We’re Building
  3. Step 1 — HTML Structure
  4. Step 2 — CSS Styling (Responsive)
  5. Step 3 — JavaScript Core Logic
  6. Step 4 — Touch & Swipe Support
  7. Step 5 — Keyboard Navigation & Autoplay
  8. Step 6 — Dot Indicators
  9. Complete Vanilla JS Slider Code
  10. React Slider Component (Reusable Hook)
  11. Vanilla JS vs React vs Libraries
  12. FAQ
  13. Conclusion

Why Build a Vanilla JS Slider Without jQuery?

In 2026, pulling in jQuery or a full carousel library just to slide some images is overkill. Modern browsers give you everything you need built in. Here’s why a hand-rolled vanilla JS slider is often the better choice:

Zero Dependencies

No jQuery, no SwiperJS, no Slick. Pure HTML, CSS, and JavaScript — nothing to install or update.

🪶

Tiny Footprint

jQuery alone is ~90KB. Your vanilla JS slider will be under 1KB of JavaScript.

🎛️

Full Control

No fighting against a library’s opinionated markup. Your HTML, your classes, your animations.

📱

Mobile-Ready

Add native touch/swipe support with a few lines of code — no plugin needed.


What We’re Building

By the end of this tutorial, you’ll have a slider that supports all of the following:

  • ✅ Responsive layout that works on all screen sizes
  • ✅ Smooth CSS transition animation
  • ✅ Previous / Next button navigation
  • ✅ Touch and swipe gestures (mobile)
  • ✅ Keyboard arrow key navigation
  • ✅ Autoplay with pause-on-hover
  • ✅ Clickable dot indicators
  • ✅ Infinite loop (wraps around)
Live Preview — Vanilla JS Slider
Slide 1 ← swipe or use arrows →
Slide 2 Keyboard ← → also works
Slide 3 Touch & swipe supported
Slide 4 Autoplay + dot navigation

Step 1 — HTML Structure

Start with a clean, semantic structure. The .slider-container acts as the viewport (overflow hidden), and the inner .slider-track holds all slides in a horizontal row.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vanilla JS Image Slider</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

  <div class="slider-container" id="mySlider"
       role="region" aria-label="Image slider">

    <!-- Slides track -->
    <div class="slider-track">
      <div class="slide">
        <img src="image1.jpg" alt="Mountain landscape" loading="lazy">
      </div>
      <div class="slide">
        <img src="image2.jpg" alt="Ocean view" loading="lazy">
      </div>
      <div class="slide">
        <img src="image3.jpg" alt="City skyline" loading="lazy">
      </div>
    </div>

    <!-- Navigation buttons -->
    <button class="slider-btn slider-prev" aria-label="Previous slide">&#8249;</button>
    <button class="slider-btn slider-next" aria-label="Next slide">&#8250;</button>

    <!-- Dot indicators (populated by JS) -->
    <div class="slider-dots" aria-label="Slide indicators"></div>

  </div>

  <script src="script.js"></script>
</body>
</html>
💡
Accessibility note: Adding role="region", aria-label, and descriptive alt text to every image makes your slider screen-reader friendly — and it’s also a good SEO signal.

Step 2 — CSS Styling (Responsive)

The key layout trick: .slider-container hides overflow so only one slide is visible at a time. The .slider-track is a flex row wider than the container, and we slide it with transform: translateX() — which is GPU-accelerated and doesn’t trigger layout reflow.

/* ── Viewport container ── */
.slider-container {
  position: relative;
  width: 100%;
  max-width: 800px;
  margin: 0 auto;
  overflow: hidden;       /* hides all slides except the active one */
  border-radius: 12px;
  box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
  user-select: none;      /* prevents text selection while dragging */
}

/* ── Horizontal slide track ── */
.slider-track {
  display: flex;
  transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform;  /* GPU compositing hint */
}

/* ── Individual slide ── */
.slide {
  min-width: 100%;         /* each slide = full container width */
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

.slide img {
  width: 100%;
  height: 100%;
  object-fit: cover;      /* fills the slide without distortion */
  display: block;
  pointer-events: none;  /* prevents drag on images interfering with swipe */
}

/* ── Prev / Next Buttons ── */
.slider-btn {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(0, 0, 0, 0.45);
  backdrop-filter: blur(6px);
  color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.2);
  width: 42px;
  height: 42px;
  border-radius: 50%;
  font-size: 22px;
  cursor: pointer;
  z-index: 2;
  transition: background 0.2s;
  display: flex;
  align-items: center;
  justify-content: center;
}
.slider-btn:hover { background: rgba(0, 0, 0, 0.75); }
.slider-prev      { left:  14px; }
.slider-next      { right: 14px; }

/* ── Dot indicators ── */
.slider-dots {
  position: absolute;
  bottom: 14px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 7px;
  z-index: 2;
}
.dot {
  width:  9px;
  height: 9px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.4);
  border: none;
  cursor: pointer;
  padding: 0;
  transition: all 0.2s;
}
.dot.active {
  background: #fff;
  transform: scale(1.3);
}
ℹ️
Why transform: translateX()? Animating with transform triggers GPU compositing — the browser does not repaint or reflow the page. Animating left or margin-left instead would cause expensive layout recalculations on every frame.

Step 3 — JavaScript Core Logic

Let’s query our DOM elements and write the core goToSlide() function that everything else will call:

// ── Query DOM elements ──────────────────────────────
const container = document.getElementById('mySlider');
const track     = container.querySelector('.slider-track');
const slides    = container.querySelectorAll('.slide');
const prevBtn   = container.querySelector('.slider-prev');
const nextBtn   = container.querySelector('.slider-next');
const dotsEl   = container.querySelector('.slider-dots');

let currentIndex = 0;
const total       = slides.length;

// ── Core: move to a specific slide index ─────────────
function goToSlide(index) {
  // Wrap around for infinite loop
  currentIndex = (index + total) % total;
  track.style.transform = `translateX(-${currentIndex * 100}%)`;
  updateDots();
}

// ── Button click handlers ────────────────────────────
prevBtn.addEventListener('click', () => goToSlide(currentIndex - 1));
nextBtn.addEventListener('click', () => goToSlide(currentIndex + 1));

Step 4 — Touch & Swipe Support

Record the X position on touchstart, compare it on touchend, and navigate if the difference is large enough to be intentional (50px threshold filters out accidental taps).

let touchStartX = 0;
let isDragging  = false;

track.addEventListener('touchstart', (e) => {
  touchStartX = e.touches[0].clientX;
}, { passive: true });

track.addEventListener('touchend', (e) => {
  const diff = touchStartX - e.changedTouches[0].clientX;
  if (Math.abs(diff) < 50) return; // ignore small movements (taps)
  goToSlide(diff > 0 ? currentIndex + 1 : currentIndex - 1);
}, { passive: true });

// Mouse drag support (desktop)
track.addEventListener('mousedown', (e) => {
  touchStartX = e.clientX;
  isDragging = true;
});
document.addEventListener('mouseup', (e) => {
  if (!isDragging) return;
  isDragging = false;
  const diff = touchStartX - e.clientX;
  if (Math.abs(diff) < 50) return;
  goToSlide(diff > 0 ? currentIndex + 1 : currentIndex - 1);
});

Step 5 — Keyboard Navigation & Autoplay

// ── Keyboard navigation ──────────────────────────────
document.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft')  goToSlide(currentIndex - 1);
  if (e.key === 'ArrowRight') goToSlide(currentIndex + 1);
});

// ── Autoplay (pauses on hover) ────────────────────────
let autoplayTimer;

function startAutoplay(interval = 4000) {
  autoplayTimer = setInterval(() => goToSlide(currentIndex + 1), interval);
}

function stopAutoplay() {
  clearInterval(autoplayTimer);
}

// Pause on hover — resume when mouse leaves
container.addEventListener('mouseenter', stopAutoplay);
container.addEventListener('mouseleave', startAutoplay);

startAutoplay(); // kick it off

Step 6 — Dot Indicators

// ── Build dots dynamically ───────────────────────────
function buildDots() {
  slides.forEach((_, i) => {
    const dot = document.createElement('button');
    dot.className    = 'dot';
    dot.ariaLabel    = `Go to slide ${i + 1}`;
    dot.addEventListener('click', () => goToSlide(i));
    dotsEl.appendChild(dot);
  });
  updateDots();
}

function updateDots() {
  document.querySelectorAll('.dot').forEach((dot, i) => {
    dot.classList.toggle('active', i === currentIndex);
  });
}

buildDots();

Complete Vanilla JS Slider — Full Code

Here is the entire script.js assembled in one place — copy-paste ready:

// ── Vanilla JS Image Slider — Full Implementation ────
(function () {
  const container    = document.getElementById('mySlider');
  const track        = container.querySelector('.slider-track');
  const slides       = container.querySelectorAll('.slide');
  const prevBtn      = container.querySelector('.slider-prev');
  const nextBtn      = container.querySelector('.slider-next');
  const dotsEl       = container.querySelector('.slider-dots');

  let currentIndex  = 0;
  let touchStartX   = 0;
  let isDragging    = false;
  let autoplayTimer;
  const total       = slides.length;

  // Navigate to slide index (infinite wrap)
  function goToSlide(index) {
    currentIndex = (index + total) % total;
    track.style.transform = `translateX(-${currentIndex * 100}%)`;
    updateDots();
  }

  // Dots
  function buildDots() {
    slides.forEach((_, i) => {
      const btn = document.createElement('button');
      btn.className = 'dot';
      btn.ariaLabel = `Slide ${i + 1}`;
      btn.addEventListener('click', () => goToSlide(i));
      dotsEl.appendChild(btn);
    });
    updateDots();
  }
  function updateDots() {
    dotsEl.querySelectorAll('.dot').forEach((d, i) =>
      d.classList.toggle('active', i === currentIndex)
    );
  }

  // Buttons
  prevBtn.addEventListener('click', () => goToSlide(currentIndex - 1));
  nextBtn.addEventListener('click', () => goToSlide(currentIndex + 1));

  // Touch / swipe
  track.addEventListener('touchstart', e => touchStartX = e.touches[0].clientX, { passive: true });
  track.addEventListener('touchend', e => {
    const d = touchStartX - e.changedTouches[0].clientX;
    if (Math.abs(d) > 50) goToSlide(currentIndex + (d > 0 ? 1 : -1));
  }, { passive: true });
  track.addEventListener('mousedown', e => { touchStartX = e.clientX; isDragging = true; });
  document.addEventListener('mouseup', e => {
    if (!isDragging) return;
    isDragging = false;
    const d = touchStartX - e.clientX;
    if (Math.abs(d) > 50) goToSlide(currentIndex + (d > 0 ? 1 : -1));
  });

  // Keyboard
  document.addEventListener('keydown', e => {
    if (e.key === 'ArrowLeft')  goToSlide(currentIndex - 1);
    if (e.key === 'ArrowRight') goToSlide(currentIndex + 1);
  });

  // Autoplay
  const startAutoplay = () => autoplayTimer = setInterval(() => goToSlide(currentIndex + 1), 4000);
  const stopAutoplay  = () => clearInterval(autoplayTimer);
  container.addEventListener('mouseenter', stopAutoplay);
  container.addEventListener('mouseleave', startAutoplay);

  // Init
  buildDots();
  startAutoplay();

})();

React Slider Component — Reusable Hook Approach

If you’re working in a React or Next.js project, building a slider component is even cleaner. We’ll use a custom useSlider hook to encapsulate all the logic, and a separate <Slider> component that is fully reusable and prop-driven.

The useSlider Hook

import { useState, useEffect, useCallback, useRef } from 'react';

/**
 * useSlider — encapsulates all slider state and behaviour
 * @param {number} total      - Total number of slides
 * @param {object} options    - { autoplay, interval, keyboard }
 */
export function useSlider(total, { autoplay = true, interval = 4000, keyboard = true } = {}) {
  const [index, setIndex] = useState(0);
  const timerRef         = useRef(null);
  const isHovering       = useRef(false);

  const goTo = useCallback((i) => {
    setIndex((prev) => (i + total) % total);
  }, [total]);

  const prev = useCallback(() => goTo(index - 1), [index, goTo]);
  const next = useCallback(() => goTo(index + 1), [index, goTo]);

  // Autoplay
  useEffect(() => {
    if (!autoplay) return;
    timerRef.current = setInterval(() => {
      if (!isHovering.current) next();
    }, interval);
    return () => clearInterval(timerRef.current);
  }, [autoplay, interval, next]);

  // Keyboard
  useEffect(() => {
    if (!keyboard) return;
    const onKey = (e) => {
      if (e.key === 'ArrowLeft')  prev();
      if (e.key === 'ArrowRight') next();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [keyboard, prev, next]);

  return {
    index,
    goTo,
    prev,
    next,
    isHovering,  // pass ref to onMouseEnter/Leave
  };
}

The <Slider> Component

import { useState, useRef } from 'react';
import { useSlider } from './useSlider';
import './Slider.css';

/**
 * <Slider> — reusable image slider component
 *
 * Props:
 *   slides   {Array}   - [{ src, alt }, ...]
 *   autoplay {boolean} - default true
 *   interval {number}  - ms between slides, default 4000
 */
export default function Slider({ slides, autoplay = true, interval = 4000 }) {
  const touchStartX = useRef(0);

  const { index, goTo, prev, next, isHovering } = useSlider(
    slides.length,
    { autoplay, interval }
  );

  const handleTouchStart = (e) => {
    touchStartX.current = e.touches[0].clientX;
  };
  const handleTouchEnd = (e) => {
    const diff = touchStartX.current - e.changedTouches[0].clientX;
    if (Math.abs(diff) > 50) diff > 0 ? next() : prev();
  };

  return (
    <div
      className="slider-container"
      role="region"
      aria-label="Image slider"
      onMouseEnter={() => (isHovering.current = true)}
      onMouseLeave={() => (isHovering.current = false)}
    >
      <div
        className="slider-track"
        style={{ transform: `translateX(-${index * 100}%)` }}
        onTouchStart={handleTouchStart}
        onTouchEnd={handleTouchEnd}
      >
        {slides.map((slide, i) => (
          <div className="slide" key={i}>
            <img src={slide.src} alt={slide.alt} loading="lazy" />
          </div>
        ))}
      </div>

      <button className="slider-btn slider-prev" onClick={prev} aria-label="Previous">‹</button>
      <button className="slider-btn slider-next" onClick={next} aria-label="Next">›</button>

      <div className="slider-dots">
        {slides.map((_, i) => (
          <button
            key={i}
            className={`dot ${i === index ? 'active' : ''}`}
            onClick={() => goTo(i)}
            aria-label={`Slide ${i + 1}`}
          />
        ))}
      </div>
    </div>
  );
}

Using the Component

import Slider from './Slider';

const slides = [
  { src: '/images/mountain.jpg', alt: 'Mountain landscape at sunset' },
  { src: '/images/ocean.jpg',    alt: 'Ocean waves at dawn' },
  { src: '/images/city.jpg',     alt: 'City skyline at night' },
];

export default function App() {
  return (
    <main>
      <Slider
        slides={slides}
        autoplay={true}
        interval={5000}
      />
    </main>
  );
}
⚠️
React pitfall: Always return a cleanup function from every useEffect that registers event listeners or timers. Missing cleanup in the useSlider hook would cause interval callbacks to fire on unmounted components and create memory leaks.

Vanilla JS vs React vs Third-Party Libraries

Approach Bundle size Reusability State integration Best for
Vanilla JS ~1 KB Manual None Static sites, HTML pages
React Hook ~1 KB Hook + Component Full useState React / Next.js apps
SwiperJS ~140 KB High Via API Complex carousels fast
Slick Carousel ~280 KB + jQuery High jQuery only Legacy projects
Embla Carousel ~20 KB High React plugin React, good balance

Frequently Asked Questions

What is a vanilla JS slider?

A vanilla JS slider (also called a vanilla JavaScript carousel) is an image or content slideshow built using only pure JavaScript, HTML, and CSS — with no external libraries like jQuery, SwiperJS, or Slick. It gives you full control over behaviour and keeps your bundle size minimal.

How do I build a slider without jQuery?

Use CSS flexbox for the layout, CSS transform: translateX() for GPU-accelerated animation, and vanilla JavaScript addEventListener for button clicks, keyboard input, and touch/swipe. The full approach is covered step by step in this tutorial.

Is a vanilla JS slider mobile-friendly?

Yes — by listening to touchstart and touchend events and comparing the X positions, you can add full swipe gesture support. Use { passive: true } on touch listeners for maximum mobile scroll performance.

Should I use a library like SwiperJS instead?

For most use cases, a hand-rolled vanilla JS or React slider is lighter and faster than SwiperJS (~140KB). Use SwiperJS if you need advanced features like loop cloning, vertical sliders, or complex effects — and page weight isn’t a concern.

What’s the difference between a slider and a carousel?

The terms are largely interchangeable. “Slider” typically implies one slide visible at a time with a horizontal transition. “Carousel” sometimes implies showing multiple partial items at once. The code structure is nearly identical for both.


Conclusion

You now have everything you need to build a production-ready, responsive slider in both plain JavaScript and React — without pulling in a single dependency. Here’s the summary:

  • Vanilla JS — best for static HTML/CSS sites. Use transform: translateX(), not left, for GPU animation. Add { passive: true } on touch listeners.
  • React — extract logic into a useSlider custom hook; always clean up event listeners and timers in your useEffect return.
  • Features covered — prev/next buttons, touch & swipe, keyboard nav, autoplay with pause-on-hover, dot indicators, infinite loop.
  • Performance — GPU-composited transitions, loading="lazy" images, passive listeners — all combine for smooth 60fps scrolling on mobile.

A fast, accessible slider improves dwell time and engagement — both signals that feed into better search rankings. Build it light, build it accessible, and it will serve you well.


Posted

in

by

Advertisement