Image Slider in JavaScript: Accessible and Responsive

Sep 4, 2026·26 min read

Three product photographs sat inside a slider that worked with a mouse and nowhere else. The arrows were spans, the dots had no names, a narrow screen stretched every image, and turning JavaScript off removed the entire gallery.

An accessible responsive image slider uses semantic content, native buttons, one JavaScript index, keyboard and swipe navigation, motion preferences, and responsive images that remain useful without JavaScript.

What we are building and how the slider works

An image slider, also called a carousel, presents an ordered set of slides within one viewing area. One slide is current, and controls move the viewing area through the set.

The component has five parts:

A stationary window over a moving rowCurrentslideNextslideVIEWPORTTRACK moves behind the window
The viewport reveals one part of a wider moving track.
  • The viewport is the visible window that clips the other slides.
  • The track is the row that moves left and right.
  • A slide contains one image and its caption.
  • The previous and next buttons move one position in either direction.
  • The slide-picker dots move directly to a named position.

JavaScript stores the active position in one zero-based variable named currentIndex. A value of 0 means the first slide, 1 means the second, and 2 means the third.

Every input changes that value. The arrow buttons, dot buttons, swipe gesture, and optional rotation never move the track independently. They call the same navigation function, which updates currentIndex and asks one render function to synchronize the interface.

That separation keeps the state small. The track position, current dot, accessible slide state, and visible position text are outputs of currentIndex, not competing copies of it.

One source of truthArrow buttonPicker dotKeyboardSwipecurrentIndexrenderTrackpositionDots andtextA11ystateInputs do notmove the trackthemselves.
Every input changes one index; render derives every output.

The finished example also uses progressive enhancement. Before JavaScript runs, the page shows three figures in document order without carousel roles, a live region, or focusable slider controls. After initialization, JavaScript adds the interactive semantics and a class that turns those figures into a sliding track, then reveals the controls.

Before JavaScriptAfter initializationFigure 1Figure 2Figure 3All content is readableJSFigure 1Clipped viewportSame content, new controls
Progressive enhancement preserves the content and adds interaction.

Write semantic slider HTML

Start with content that makes sense before it becomes interactive. An ordered list preserves the image sequence because each li keeps its list-item role, each figure connects an image with its caption, and the controls use real button elements:

<section
  class="slider"
  data-slider
  data-label="Coastal walking route"
>
  <button type="button" data-rotation hidden>
    Start automatic slides
  </button>
  <div class="slider__viewport">
    <ol class="slider__track" data-track>
      <li class="slide">
        <figure>
          <div class="slide__media">
            <img
              src="images/cliffs-960.jpg"
              srcset="
                images/cliffs-640.jpg 640w,
                images/cliffs-960.jpg 960w,
                images/cliffs-1440.jpg 1440w
              "
              sizes="(min-width: 900px) 800px, 100vw"
              width="1440"
              height="810"
              loading="eager"
              alt="A narrow path following grass-covered cliffs above the sea"
            >
            <span class="slide__fallback" hidden>
              The cliff photograph could not be loaded.
            </span>
          </div>
          <figcaption>Cliff path, the first section of the route</figcaption>
        </figure>
      </li>

      <li
        class="slide"
        id="slide-2"
        role="group"
        aria-roledescription="slide"
        aria-label="2 of 3"
      >
        <figure>
          <div class="slide__media">
            <img
              src="images/cove-960.jpg"
              srcset="
                images/cove-640.jpg 640w,
                images/cove-960.jpg 960w,
                images/cove-1440.jpg 1440w
              "
              sizes="(min-width: 900px) 800px, 100vw"
              width="1440"
              height="810"
              loading="lazy"
              alt="A sheltered blue cove between two rocky headlands"
            >
            <span class="slide__fallback" hidden>
              The cove photograph could not be loaded.
            </span>
          </div>
          <figcaption>Blue cove, halfway along the route</figcaption>
        </figure>
      </li>

      <li
        class="slide"
        id="slide-3"
        role="group"
        aria-roledescription="slide"
        aria-label="3 of 3"
      >
        <figure>
          <div class="slide__media">
            <img
              src="images/lighthouse-960.jpg"
              srcset="
                images/lighthouse-640.jpg 640w,
                images/lighthouse-960.jpg 960w,
                images/lighthouse-1440.jpg 1440w
              "
              sizes="(min-width: 900px) 800px, 100vw"
              width="1440"
              height="810"
              loading="lazy"
              alt="A white lighthouse standing beyond a field at sunset"
            >
            <span class="slide__fallback" hidden>
              The lighthouse photograph could not be loaded.
            </span>
          </div>
          <figcaption>Lighthouse, the final stop</figcaption>
        </figure>
      </li>
    </ol>
  </div>

  <div class="slider__controls" data-controls hidden>
    <button
      class="slider__arrow slider__arrow--previous"
      type="button"
      data-previous
      aria-label="Show previous slide"
    >
      <span aria-hidden="true">‹</span>
    </button>

    <button
      class="slider__arrow slider__arrow--next"
      type="button"
      data-next
      aria-label="Show next slide"
    >
      <span aria-hidden="true">›</span>
    </button>

    <div class="slider__dots" role="group" aria-label="Choose a slide">
      <button
        type="button"
        data-slide-picker="0"
        aria-label="Show slide 1: Cliff path"
      ></button>
      <button
        type="button"
        data-slide-picker="1"
        aria-label="Show slide 2: Blue cove"
      ></button>
      <button
        type="button"
        data-slide-picker="2"
        aria-label="Show slide 3: Lighthouse"
      ></button>
    </div>

    <span class="slider__position" data-position aria-hidden="true">
      1 of 3
    </span>

  </div>

  <p class="visually-hidden" data-announcement hidden>
    Slide 1 of 3: Cliff path, the first section of the route
  </p>
</section>

<script src="slider.js" defer></script>

The controls start with hidden, so dead buttons do not appear if the script fails. Each photograph remains visible with its caption, and the first image loads eagerly while the later images can wait until the browser decides they are close enough to the viewport.

The width and height attributes describe the image proportions and let the browser reserve space. Each srcset uses width descriptors, so sizes tells the browser how much layout width the image is expected to occupy when it chooses a file.

Alt text describes information supplied by the photograph. If a caption already communicates everything meaningful in an image, use alt="" instead of repeating the same sentence twice.

Create the responsive sliding layout

The unenhanced rules keep the figures readable. Slider-specific positioning begins under .slider--ready, the class JavaScript adds only after it finds the required elements:

[hidden] {
  display: none !important;
}

.slider {
  max-width: 50rem;
  margin-inline: auto;
  color: #172033;
}

.slider__track {
  margin: 0;
  padding: 0;
  list-style: none;
}

.slide figure {
  margin: 0 0 1.5rem;
}

.slide__media {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: 0.75rem;
  background: #dbe2ea;
}

.slide img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.slide__fallback {
  display: grid;
  width: 100%;
  height: 100%;
  place-items: center;
  padding: 1rem;
  text-align: center;
}

.slide figcaption {
  padding: 0.75rem 0.25rem;
}

.slider--ready {
  position: relative;
  touch-action: pan-y;
}

.slider--ready .slider__viewport {
  overflow: hidden;
  border-radius: 0.75rem;
}

.slider--ready .slider__track {
  display: flex;
  transition: transform 350ms ease;
  will-change: transform;
}

.slider--ready .slide {
  flex: 0 0 100%;
  min-width: 0;
}

.slider--ready .slide figure {
  margin: 0;
}

.slider--ready .slider__controls {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 0.75rem;
  align-items: center;
  margin-top: 0.75rem;
}

.slider__arrow,
.slider__dots button,
[data-rotation] {
  min-width: 2.75rem;
  min-height: 2.75rem;
  border: 1px solid currentColor;
  background: white;
  color: inherit;
  cursor: pointer;
}

.slider__dots button[aria-disabled="true"] {
  cursor: default;
  opacity: 0.65;
}

.slider__arrow {
  position: absolute;
  z-index: 1;
  top: calc(50% - 3rem);
  border-radius: 999px;
  font-size: 1.75rem;
}

.slider__arrow--previous {
  left: 0.75rem;
}

.slider__arrow--next {
  right: 0.75rem;
}

.slider__dots {
  display: flex;
  grid-column: 2;
  gap: 0.5rem;
  justify-content: center;
}

.slider__dots button {
  min-width: 0.85rem;
  min-height: 0.85rem;
  padding: 0;
  border-radius: 50%;
  background: transparent;
}

.slider__dots button[aria-current="true"] {
  background: currentColor;
}

.slider__position {
  justify-self: end;
  font-variant-numeric: tabular-nums;
}

[data-rotation] {
  grid-column: 1 / -1;
  justify-self: center;
  padding-inline: 1rem;
  border-radius: 0.35rem;
}

.slider :focus-visible {
  outline: 0.2rem solid #075fd8;
  outline-offset: 0.2rem;
}

.slide--image-error img {
  display: none;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (max-width: 36rem) {
  .slider__arrow {
    top: calc(50% - 3.5rem);
  }

  .slider__position {
    grid-column: 1 / -1;
    justify-self: center;
  }
}

@media (prefers-reduced-motion: reduce) {
  .slider--ready .slider__track {
    transition-duration: 0.001ms;
  }
}

Each .slide occupies exactly 100% of the viewport, so moving the track by -100% reveals the second slide and -200% reveals the third. The container changes width with the page, but that relationship stays intact. Element size and scrolling covers the measurements behind more elaborate layouts.

aspect-ratio: 16 / 9 gives every media box the same shape. object-fit: cover fills that box and crops overflow, which keeps portrait and landscape source files from changing the slider height. Change it to contain when seeing the entire image matters more than filling the frame.

covercontainFrame is fullEdges are croppedWhole image fitsSpace may remain
Cover fills and crops; contain preserves the whole image.

The transition belongs to CSS because the state change is a transform. CSS animations and JavaScript animations separate this kind of declarative movement from a timing loop controlled by JavaScript.

The reduced-motion query responds to a motion preference enabled in the device settings. Its rule makes the change effectively instant without removing navigation.

Add the JavaScript state and navigation

The script is organized in stages: query the elements, validate the required core, create state, render that state, connect navigation, then add swipe, optional rotation, and image-error handling. Spread syntax turns the queried slides and dots into arrays; optional chaining skips a missing optional element; dataset reads a data-* value; and the modulo expression wraps an index around either end.

The script initializes every [data-slider] on the page independently. If a slider is incomplete or contains no slides, the function returns and leaves the original figures alone:

let sliderInstanceCount = 0;

document.querySelectorAll('[data-slider]').forEach(initSlider);

function initSlider(slider) {
  const track = slider.querySelector('[data-track]');
  const slides = [...slider.querySelectorAll('.slide')];
  const controls = slider.querySelector('[data-controls]');
  const previousButton = slider.querySelector('[data-previous]');
  const nextButton = slider.querySelector('[data-next]');
  const rotationButton = slider.querySelector('[data-rotation]');
  const position = slider.querySelector('[data-position]');
  const announcement = slider.querySelector('[data-announcement]');
  const dots = [...slider.querySelectorAll('[data-slide-picker]')];

  if (
    !track ||
    slides.length === 0 ||
    !controls ||
    !previousButton ||
    !nextButton ||
    !position ||
    !announcement
  ) {
    return;
  }

  const instanceId = `slider-${++sliderInstanceCount}`;

  slider.setAttribute('role', 'region');
  slider.setAttribute('aria-roledescription', 'carousel');
  slider.setAttribute(
    'aria-label',
    slider.dataset.label || 'Image carousel'
  );
  announcement.setAttribute('aria-live', 'polite');

  slides.forEach((slide, index) => {
    const slideId = `${instanceId}-slide-${index + 1}`;
    const slideContent = slide.querySelector('figure');

    slide.id = slideId;
    slide.removeAttribute('role');
    slide.removeAttribute('aria-roledescription');
    slide.removeAttribute('aria-label');
    dots[index]?.setAttribute('aria-controls', slideId);

    if (slideContent) {
      slideContent.setAttribute('role', 'group');
      slideContent.setAttribute('aria-roledescription', 'slide');
      slideContent.setAttribute(
        'aria-label',
        `${index + 1} of ${slides.length}`
      );
    }
  });

  let currentIndex = 0;
  let rotationTimer = null;
  let rotationRequested = false;
  let pointerIsOver = false;
  let pointerStart = null;

  function wrapIndex(index) {
    return ((index % slides.length) + slides.length) % slides.length;
  }

  function render({ announce = true } = {}) {
    track.style.transform = `translateX(-${currentIndex * 100}%)`;

    slides.forEach((slide, index) => {
      const isCurrent = index === currentIndex;

      slide.setAttribute('aria-hidden', String(!isCurrent));
      slide.toggleAttribute('inert', !isCurrent);
    });

    dots.forEach((dot, index) => {
      if (index === currentIndex) {
        dot.setAttribute('aria-current', 'true');
        dot.setAttribute('aria-disabled', 'true');
      } else {
        dot.removeAttribute('aria-current');
        dot.removeAttribute('aria-disabled');
      }
    });

    const visibleNumber = currentIndex + 1;
    const caption =
      slides[currentIndex].querySelector('figcaption')?.textContent.trim() ??
      `Slide ${visibleNumber}`;

    position.textContent = `${visibleNumber} of ${slides.length}`;

    if (announce) {
      announcement.textContent =
        `Slide ${visibleNumber} of ${slides.length}: ${caption}`;
    }
  }

  function goToSlide(index, options) {
    currentIndex = wrapIndex(index);
    render(options);
  }

  function nextSlide(options) {
    goToSlide(currentIndex + 1, options);
  }

  function previousSlide(options) {
    goToSlide(currentIndex - 1, options);
  }

  function pauseRotation() {
    if (rotationTimer !== null) {
      window.clearInterval(rotationTimer);
      rotationTimer = null;
    }
  }

  function stopRotation() {
    pauseRotation();
    rotationRequested = false;

    if (rotationButton) {
      rotationButton.textContent = 'Start automatic slides';
    }

    announcement.setAttribute('aria-live', 'polite');
  }

  function startRotation() {
    if (!rotationButton) {
      return;
    }

    pauseRotation();
    rotationRequested = true;
    rotationButton.textContent = 'Stop automatic slides';
    announcement.setAttribute('aria-live', 'off');

    if (pointerIsOver) {
      return;
    }

    rotationTimer = window.setInterval(() => {
      nextSlide({ announce: false });
    }, 5000);
  }

  previousButton.addEventListener('click', () => {
    stopRotation();
    previousSlide();
  });

  nextButton.addEventListener('click', () => {
    stopRotation();
    nextSlide();
  });

  dots.forEach((dot) => {
    dot.addEventListener('click', () => {
      const targetIndex = Number(dot.dataset.slidePicker);

      if (targetIndex === currentIndex) {
        return;
      }

      stopRotation();
      goToSlide(targetIndex);
    });
  });

  rotationButton?.addEventListener('click', () => {
    if (!rotationRequested) {
      startRotation();
    } else {
      stopRotation();
    }
  });

  slider.addEventListener('keydown', (event) => {
    if (event.target !== slider) {
      return;
    }

    if (event.key === 'ArrowLeft') {
      event.preventDefault();
      stopRotation();
      previousSlide();
    }

    if (event.key === 'ArrowRight') {
      event.preventDefault();
      stopRotation();
      nextSlide();
    }

    if (event.key === 'Home') {
      event.preventDefault();
      stopRotation();
      goToSlide(0);
    }

    if (event.key === 'End') {
      event.preventDefault();
      stopRotation();
      goToSlide(slides.length - 1);
    }
  });

  slider.addEventListener('pointerdown', (event) => {
    if (!event.isPrimary || event.target.closest('button')) {
      return;
    }

    pointerStart = {
      id: event.pointerId,
      x: event.clientX,
      y: event.clientY,
    };
  });

  slider.addEventListener('pointerup', (event) => {
    if (!pointerStart || event.pointerId !== pointerStart.id) {
      return;
    }

    const distanceX = event.clientX - pointerStart.x;
    const distanceY = event.clientY - pointerStart.y;
    pointerStart = null;

    const isHorizontal =
      Math.abs(distanceX) > 50 &&
      Math.abs(distanceX) > Math.abs(distanceY);

    if (!isHorizontal) {
      return;
    }

    stopRotation();

    if (distanceX < 0) {
      nextSlide();
    } else {
      previousSlide();
    }
  });

  slider.addEventListener('pointercancel', () => {
    pointerStart = null;
  });

  slider.addEventListener('focusin', (event) => {
    if (event.target !== rotationButton) {
      stopRotation();
    }
  });

  slider.addEventListener('pointerenter', () => {
    pointerIsOver = true;

    if (rotationTimer !== null) {
      stopRotation();
    }
  });

  slider.addEventListener('pointerleave', () => {
    pointerIsOver = false;

    if (rotationRequested && rotationTimer === null) {
      startRotation();
    }
  });

  slides.forEach((slide) => {
    const image = slide.querySelector('img');
    const fallback = slide.querySelector('.slide__fallback');

    const showFallback = () => {
      slide.classList.add('slide--image-error');
      image.hidden = true;

      if (fallback) {
        fallback.hidden = false;
      }
    };

    image?.addEventListener('error', showFallback);

    if (image?.complete && image.naturalWidth === 0) {
      showFallback();
    }
  });

  slider.classList.add('slider--ready');
  controls.hidden = false;
  announcement.hidden = false;
  previousButton.disabled = slides.length < 2;
  nextButton.disabled = slides.length < 2;

  if (rotationButton) {
    rotationButton.hidden = false;
    rotationButton.disabled = slides.length < 2;
  }

  render({ announce: false });
}

The wrap formula handles both sides of the list:

((index % slideCount) + slideCount) % slideCount

With three slides, moving forward from index 2 produces 3, which wraps to 0. Moving backward from index 0 produces -1; the extra addition turns the negative remainder into index 2.

goToSlide is the only function that assigns a new position. nextSlide and previousSlide express direction, while render turns the resulting state into visible and accessible changes.

That boundary matters. Adding another input later, such as thumbnail images, requires one call to goToSlide. It does not require another version of the movement logic.

The script adds and removes a class through classList, a pattern covered further in Styles and classes.

Add dots, keyboard controls, and swipe gestures

Each dot carries its zero-based destination in data-slide-picker. Clicking the third dot passes 2 to goToSlide, and render moves the track, marks that dot with aria-current="true", updates the visible 3 of 3 label, and changes the live-region text.

The dots remain ordinary buttons. A keyboard user reaches each one with Tab and activates it with the button’s native keyboard behavior. The script does not replace Tab or Shift+Tab.

Swipe navigation uses Pointer Events, so the same handler can receive touch, pen, and mouse input. It records the starting coordinates and waits until contact ends before deciding whether the gesture counts.

A horizontal movement must exceed 50 pixels and be larger than the vertical movement. A short tap changes nothing, and a mostly vertical gesture remains page scrolling. The CSS declaration touch-action: pan-y makes that vertical intent explicit.

Which gesture becomes a swipe?SShort: no changeChangeslideScroll the pageDiagonal:vertical winsSwipe only when |x| > 50 and |x| > |y|
Swipe detection compares both distance and direction.

Swipe is an addition, not the only mobile control. The previous, next, and dot buttons remain visible and usable when a gesture is difficult or unavailable.

The WAI-ARIA carousel pattern describes a slide as one content container in a presented set. It distinguishes previous, next, rotation, and slide-picker controls, which are the same names used throughout this implementation.

The outer container has role="region", aria-roledescription="carousel", and the accessible label Coastal walking route. A carousel embedded inside a larger composite widget can use role="group" instead.

Each slide also uses role="group" and aria-roledescription="slide". Its label presents the zero-based program state as a one-based human position: 1 of 3, 2 of 3, or 3 of 3.

Native buttons supply focus behavior and activation semantics. The arrow glyphs are hidden from assistive technology because the button labels already say Show previous slide and Show next slide.

Inactive slides receive both aria-hidden="true" and inert. The first removes them from the accessibility tree, while the second prevents descendants from remaining interactive. That becomes necessary when a future slide contains a link, video control, or form field rather than an image alone.

Off-screen must also mean unavailableCurrent slidevisibleInactive slideoff-screenIn accessibilitytreearia-hidden:not in treeFocus may enterinert: focus skipsACTIVEINACTIVE
Inactive slides are hidden from assistive technology and made non-interactive.

The live region announces direct navigation politely. Automatic changes call render({ announce: false }) while aria-live is off, so rotation does not produce a new spoken message every five seconds.

The example includes that rotation control without enabling it automatically. Direct navigation stops rotation, focus entering another carousel control stops it, and pointer entry stops it. The visitor must activate Start automatic slides to begin again.

The visitor remains in controlOFFby defaultROTATINGevery 5 sPAUSEDpointer overUser selectsStartPointer entersPointer leaves;request remainsDirect navigation stops it
Automatic rotation starts by choice and yields to the visitor.

Reduced motion is separate from autoplay. The media query removes the visible sliding duration for someone who has requested less motion, while every button and state update still works. More elaborate effects can use The Web Animations API, but a transform transition is enough here.

This component uses browser features that also appear throughout The Browser Platform: semantic controls first, CSS for presentation, and JavaScript for the state that connects them.

Optimize and test the finished slider

The visible first image uses loading="eager". Later images use loading="lazy", which lets the browser defer them until they fall within its calculated distance from the viewport.

Every image supplies width, height, srcset, and sizes. The dimensions reserve a stable box, while the responsive candidates let the browser choose a resource for the expected display width. The fixed media ratio and object-fit then handle source photographs with different shapes.

An image error replaces the broken image with text inside the same media box. JavaScript failure takes a different path: controls stay hidden, no enhancement class is added, and the three figures remain readable.

Two failures, two safe fallbacksWhat failed?One imageJavaScriptFallback text fillsthe same boxAll figures stayControls stayhiddenLayout remains stableContent remains readable
Image failure and JavaScript failure preserve content in different ways.

Run the finished page through this manual test matrix. In browser developer tools, disable JavaScript, block one image URL, apply network throttling, emulate a narrow touch device, and emulate prefers-reduced-motion: reduce; also resize the page without emulation. Test the screen-reader row with NVDA and Firefox on Windows and with VoiceOver and Safari on an Apple device:

TestExpected result
JavaScript disabledAll figures, images, alt text, and captions remain in document order; slider controls stay hidden.
Keyboard onlyButtons work with their native keys, the rotation control is reached first, and Tab remains ordinary.
Screen readerThe carousel and slides have names, direct changes are announced politely, and inactive slides are unavailable.
Touch inputHorizontal swipes change slides; short and vertical gestures do not.
Narrow and wide viewportsThe viewport follows its container, images keep the chosen ratio, and captions remain visible.
Reduced motion enabledSlide changes happen without a visible travel animation.
Failed image requestThe failed image disappears and its fallback message occupies the media box.
Slow image loadingThe first image starts immediately, later images may be deferred, and reserved dimensions keep the layout stable.
Optional rotationRotation begins only after activation, stops on interaction, and does not announce automatic movement.

One currentIndex still controls every result. That is the part to protect when the component gains thumbnails, more slides, or different transitions.

Frequently asked questions

How do you make an image slider in JavaScript?
Store the active slide in one currentIndex value, then use a render function to translate the slide track and synchronize the controls. Previous, next, dot, keyboard, and swipe inputs should all update that same value.
How do you make a JavaScript image slider accessible?
Use a labelled carousel region, native buttons, accessible slide names, visible focus styles, keyboard controls, and a polite live region. Inactive slides must also be removed from assistive technology and the tab order.
How do you make an image slider responsive?
Give each slide a width of 100%, let images fill a fixed aspect-ratio box with object-fit, and supply srcset and sizes so the browser can choose a suitable image. Explicit image width and height reserve space while each file loads.
Should an image carousel autoplay?
Autoplay should be optional and off by default. If rotation is available, provide a button that starts and stops it, pause when focus enters or the pointer hovers, and avoid announcing every automatic change.
How do you add swipe navigation to an image slider?
Record the pointer position when contact starts, compare it with the ending position, and change slides only when the horizontal distance passes a threshold and exceeds the vertical distance. This leaves ordinary vertical page scrolling available.