Image Slider JavaScript: Build an Accessible Carousel

Sep 28, 2026·21 min read

Three photographs sat inside a slider that worked until someone swiped, pressed Tab, resized the window, or disabled JavaScript. The pictures were present, but the component had four different ideas about which one was current.

An accessible JavaScript image slider uses a horizontally scrollable CSS Scroll Snap track, native button controls, layout-derived state, responsive images, and reduced-motion handling so clicking, swiping, resizing, and browsing without JavaScript all reach the same content.

What We Are Building

An image slider, also called a carousel, presents an ordered collection of images within one viewing area. It is different from a before-and-after comparison slider, and it has nothing to do with an <input type="range"> control.

The component has five named parts:

CAROUSELTRACKSLIDE 1SLIDE 2SLIDE 3Previous123Next
The carousel contains the scrolling track and its controls.
  • The carousel is the complete labeled component.
  • A slide contains one image and its optional caption.
  • The track is the horizontal scrolling list that holds the slides.
  • The controls move to the previous or next slide.
  • The indicators show the active position and move directly to another slide.

CSS owns the basic interaction. The track scrolls horizontally, each slide fills the viewport, and Scroll Snap settles the viewport on one slide. A touch gesture, trackpad movement, or dragged scrollbar works before JavaScript runs.

JavaScript adds another route through the same track. It connects the buttons, wraps navigation at either end, and synchronizes the indicators after any kind of scrolling.

That division is progressive enhancement: the ordered images exist first, CSS adds a usable scrolling layout, and JavaScript adds controls without becoming the only way to reach the content. The finished initializer also keeps its state inside each carousel, so several sliders can run on one page without shared global variables.

JavaScriptcontrolsCSSscroll + snapHTMLordered images remain
Each enhancement rests on usable content beneath it.

If you want an automatically changing gallery instead, Image Slideshow JavaScript: Build It From Scratch covers that related pattern. This slider stays manual.

Write Semantic Slider HTML

Start with an ordered list because the sequence still means something when the carousel behavior disappears. Each list item contains a figure, which keeps the image and caption together.

The carousel receives a visible heading through aria-labelledby, plus role="region" and aria-roledescription="carousel". Each list item contains a named slide group with its position in the set.

The controls are real button elements. They already support keyboard focus and activation, so you do not need clickable spans, link-shaped buttons, inline onclick attributes, or custom Tab handling.

The complete markup uses three local image files at three widths:

<section
  class="slider"
  data-slider
  role="region"
  aria-roledescription="carousel"
  aria-labelledby="coast-slider-title"
>
  <h2 id="coast-slider-title">Coastal walking route</h2>

  <ol
    class="slider__track"
    id="coast-slider-track"
    data-slider-track
    tabindex="0"
    aria-label="Coastal walking route slides"
  >
    <li class="slider__slide" data-slide>
      <div
        role="group"
        aria-roledescription="slide"
        aria-label="Cliff path, slide 1 of 3"
      >
      <figure class="slider__figure">
        <img
          class="slider__image"
          src="images/cliffs-960.jpg"
          srcset="
            images/cliffs-640.jpg 640w,
            images/cliffs-960.jpg 960w,
            images/cliffs-1600.jpg 1600w
          "
          sizes="(min-width: 50rem) 50rem, 100vw"
          width="1600"
          height="900"
          loading="eager"
          alt="A narrow path following grass-covered cliffs above the sea"
        >
        <figcaption>Cliff path, the first section of the route</figcaption>
      </figure>
      </div>
    </li>

    <li class="slider__slide" data-slide>
      <div
        role="group"
        aria-roledescription="slide"
        aria-label="Blue cove, slide 2 of 3"
      >
      <figure class="slider__figure">
        <img
          class="slider__image"
          src="images/cove-960.jpg"
          srcset="
            images/cove-640.jpg 640w,
            images/cove-960.jpg 960w,
            images/cove-1600.jpg 1600w
          "
          sizes="(min-width: 50rem) 50rem, 100vw"
          width="1600"
          height="900"
          loading="lazy"
          alt="A sheltered blue cove between two rocky headlands"
        >
        <figcaption>Blue cove, halfway along the route</figcaption>
      </figure>
      </div>
    </li>

    <li class="slider__slide" data-slide>
      <div
        role="group"
        aria-roledescription="slide"
        aria-label="Lighthouse, slide 3 of 3"
      >
      <figure class="slider__figure">
        <img
          class="slider__image"
          src="images/lighthouse-960.jpg"
          srcset="
            images/lighthouse-640.jpg 640w,
            images/lighthouse-960.jpg 960w,
            images/lighthouse-1600.jpg 1600w
          "
          sizes="(min-width: 50rem) 50rem, 100vw"
          width="1600"
          height="900"
          loading="lazy"
          alt="A white lighthouse beyond a field at sunset"
        >
        <figcaption>Lighthouse, the final stop</figcaption>
      </figure>
      </div>
    </li>
  </ol>

  <div class="slider__controls" data-slider-controls hidden>
    <button
      class="slider__arrow"
      type="button"
      data-slider-previous
      aria-controls="coast-slider-track"
      aria-label="Show previous slide"
    >
      Previous
    </button>

    <div
      class="slider__indicators"
      data-slider-indicators
      role="group"
      aria-label="Choose a slide"
    >
      <button
        class="slider__indicator"
        type="button"
        data-slider-indicator
        aria-controls="coast-slider-track"
        aria-label="Cliff path, slide 1 of 3"
        aria-current="true"
        aria-disabled="true"
      >
        1
      </button>
      <button
        class="slider__indicator"
        type="button"
        data-slider-indicator
        aria-controls="coast-slider-track"
        aria-label="Blue cove, slide 2 of 3"
        aria-current="false"
      >
        2
      </button>
      <button
        class="slider__indicator"
        type="button"
        data-slider-indicator
        aria-controls="coast-slider-track"
        aria-label="Lighthouse, slide 3 of 3"
        aria-current="false"
      >
        3
      </button>
    </div>

    <button
      class="slider__arrow"
      type="button"
      data-slider-next
      aria-controls="coast-slider-track"
      aria-label="Show next slide"
    >
      Next
    </button>
  </div>

  <p
    class="slider__status"
    data-slider-status
    aria-live="polite"
    aria-atomic="true"
  >
    Slide 1 of 3: Cliff path, the first section of the route
  </p>
</section>

The controls begin with hidden, so dead buttons never appear when JavaScript fails to load. The list remains visible and scrollable.

Each informative image has alt text that describes the photograph. The caption instead explains its place in the route, so the two strings do different jobs.

When you place another slider on the same page, give its heading and track different IDs, then point its aria-labelledby and aria-controls attributes at those IDs. The classes and data attributes stay unchanged.

Create the Responsive Scroll-Snap Track

The track is both the list and the scroll container. Flexbox puts its children in one row, overflow-x: auto preserves native horizontal scrolling, and each slide takes 100% of the track width.

scroll-snap-type: x mandatory opts the horizontal axis into snapping. Each slide then declares scroll-snap-align: start, so its leading edge becomes a snap position.

BEFORE: between slidesSLIDEbrowser settlesAFTER: edges meetSNAPPED SLIDE
Scroll Snap settles the nearest slide against the track’s starting edge.

Add the complete slider stylesheet:

.slider {
  width: min(100%, 50rem);
  margin-inline: auto;
}

.slider__track {
  display: flex;
  gap: 0;
  margin: 0;
  padding: 0;
  overflow-x: auto;
  overscroll-behavior-inline: contain;
  scroll-behavior: smooth;
  scroll-snap-type: x mandatory;
  scrollbar-width: thin;
  list-style: none;
}

.slider__slide {
  flex: 0 0 100%;
  min-width: 0;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

.slider__figure {
  margin: 0;
}

.slider__image {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 0.5rem;
}

.slider__figure figcaption {
  padding-block: 0.75rem;
}

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

.slider__controls[hidden] {
  display: none;
}

.slider__indicators {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
}

.slider__arrow,
.slider__indicator {
  min-width: 2.75rem;
  min-height: 2.75rem;
  font: inherit;
  cursor: pointer;
}

.slider__indicator[aria-current="true"] {
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 0.25em;
}

.slider__track:focus-visible,
.slider__arrow:focus-visible,
.slider__indicator:focus-visible {
  outline: 0.2rem solid currentColor;
  outline-offset: 0.2rem;
}

.slider__arrow:disabled,
.slider__indicator:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

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

@media (prefers-reduced-motion: reduce) {
  .slider__track {
    scroll-behavior: auto;
  }
}

The result is responsive because no fixed slide width is stored in either CSS or JavaScript. A slide is always one current track width, whether that width is 320px, 50rem, or something between them.

aspect-ratio keeps each image box consistent, while object-fit: cover crops files whose proportions differ. The HTML width and height attributes give the browser the image ratio before the file arrives, and CSS scales that reserved shape to the available width.

This is also where native input survives. Touch dragging, trackpad scrolling, keyboard scrolling when the track receives focus, and dragging the scrollbar all move the same scroll container. JavaScript does not replace any of them.

For other movement primitives, CSS-animations and JavaScript animations cover the browser’s two broader animation paths.

Add Previous, Next, and Indicator Controls

One initializer receives one carousel root and queries only inside it. That boundary is what makes multiple instances independent.

The current index is not calculated from a cached image width. Instead, the initializer compares the logical starting edge of the track with every slide and picks the closest one. Layout remains the source of truth after resizing and manual scrolling.

TRACK STARTSLIDE 1SLIDE 2SLIDE335235435CURRENT = 1
The slide with the smallest edge gap becomes current.

Add this complete JavaScript after the markup, or load it with a deferred script:

function createSlider(root) {
  const track = root.querySelector('[data-slider-track]');
  const slides = Array.from(root.querySelectorAll('[data-slide]'));
  const controls = root.querySelector('[data-slider-controls]');
  const previousButton = root.querySelector('[data-slider-previous]');
  const nextButton = root.querySelector('[data-slider-next]');
  const indicators = Array.from(
    root.querySelectorAll('[data-slider-indicator]')
  );
  const status = root.querySelector('[data-slider-status]');

  if (
    !track ||
    !controls ||
    !previousButton ||
    !nextButton ||
    !status
  ) {
    return;
  }

  const slideCount = slides.length;
  let currentIndex = -1;
  let animationFrame = 0;
  let scrollEndTimer = 0;

  function nearestSlideIndex() {
    if (slideCount === 0) {
      return -1;
    }

    const trackRect = track.getBoundingClientRect();
    const isRtl = getComputedStyle(track).direction === 'rtl';
    const trackEdge = isRtl ? trackRect.right : trackRect.left;
    let nearestIndex = 0;
    let nearestDistance = Infinity;

    slides.forEach((slide, index) => {
      const slideRect = slide.getBoundingClientRect();
      const slideEdge = isRtl ? slideRect.right : slideRect.left;
      const distance = Math.abs(slideEdge - trackEdge);

      if (distance < nearestDistance) {
        nearestDistance = distance;
        nearestIndex = index;
      }
    });

    return nearestIndex;
  }

  function updateControls(index, announce = false) {
    currentIndex = index;
    const hasSeveralSlides = slideCount > 1;

    previousButton.disabled = !hasSeveralSlides;
    nextButton.disabled = !hasSeveralSlides;

    indicators.forEach((button, indicatorIndex) => {
      const isCurrent = indicatorIndex === currentIndex;

      button.setAttribute('aria-current', String(isCurrent));

      if (isCurrent) {
        button.setAttribute('aria-disabled', 'true');
      } else {
        button.removeAttribute('aria-disabled');
      }

      button.disabled = indicatorIndex >= slideCount;
    });

    if (!announce) {
      return;
    }

    if (currentIndex === -1) {
      status.textContent = 'No slides available';
      return;
    }

    const caption = slides[currentIndex]
      .querySelector('figcaption')
      ?.textContent
      .trim();

    status.textContent = caption
      ? `Slide ${currentIndex + 1} of ${slideCount}: ${caption}`
      : `Slide ${currentIndex + 1} of ${slideCount}`;
  }

  function synchronizeFromLayout() {
    updateControls(nearestSlideIndex());
  }

  function queueSynchronization() {
    if (animationFrame !== 0) {
      cancelAnimationFrame(animationFrame);
    }

    animationFrame = requestAnimationFrame(() => {
      animationFrame = 0;
      synchronizeFromLayout();
    });
  }

  function wrappedIndex(index) {
    return ((index % slideCount) + slideCount) % slideCount;
  }

  function moveTo(index) {
    if (slideCount < 2) {
      return;
    }

    const destination = wrappedIndex(index);
    const reduceMotion = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    ).matches;

    slides[destination].scrollIntoView({
      behavior: reduceMotion ? 'instant' : 'smooth',
      block: 'nearest',
      inline: 'start',
    });
  }

  previousButton.addEventListener('click', () => {
    synchronizeFromLayout();
    moveTo(currentIndex - 1);
  });

  nextButton.addEventListener('click', () => {
    synchronizeFromLayout();
    moveTo(currentIndex + 1);
  });

  indicators.forEach((button, index) => {
    button.addEventListener('click', () => {
      if (button.getAttribute('aria-disabled') === 'true') {
        return;
      }

      moveTo(index);
    });
  });

  function announceCurrentSlide() {
    clearTimeout(scrollEndTimer);
    scrollEndTimer = 0;
    updateControls(nearestSlideIndex(), true);
  }

  function queueAnnouncement() {
    clearTimeout(scrollEndTimer);
    scrollEndTimer = setTimeout(announceCurrentSlide, 150);
  }

  track.addEventListener('scroll', queueSynchronization, {
    passive: true,
  });

  if ('onscrollend' in track) {
    track.addEventListener('scrollend', announceCurrentSlide);
  } else {
    track.addEventListener('scroll', queueAnnouncement, {
      passive: true,
    });
  }

  window.addEventListener('resize', queueSynchronization);

  updateControls(nearestSlideIndex(), true);

  if (slideCount > 0) {
    controls.hidden = false;
  }
}

if (typeof document !== 'undefined') {
  document
    .querySelectorAll('[data-slider]')
    .forEach(createSlider);
}

Clicking Next begins by reading the current layout. If the visible slide is index 2 in a three-slide carousel, moveTo(3) wraps to 0. Moving backward from 0 sends -1 through the same expression and produces 2.

NEXT012PREVIOUS012
Wrapped indexes connect both ends of the carousel.

Then scrollIntoView() moves the chosen slide into its ancestor track. inline: "start" aligns the slide with the beginning of the horizontal viewport, while block: "nearest" avoids an unnecessary vertical jump. Motion is smooth unless the device reports a preference for reduced motion.

The code never asks how many pixels wide a slide was during initialization. There is no stale 400px assumption to break after a responsive layout change.

Keep State Synchronized After Swipes

A slider has more inputs than its buttons. A reader can swipe the track, use a trackpad, drag the scrollbar, resize the page, or zoom until the track receives a new width.

That is why currentIndex follows layout. During a scroll, queueSynchronization() schedules one position check for the next animation frame. Repeated scroll events replace the pending check instead of running a full slide search for every event.

When the nearest slide changes, updateControls() changes the indicator state:

  • The matching indicator receives aria-current="true" and aria-disabled="true".
  • The other indicators receive aria-current="false" and have aria-disabled removed.

The live status reports the position and caption after scrollend. Where that event is unavailable, a short debounce waits for scrolling to stop.

The same update handles the edge cases. With no slides, the index is -1, both arrow buttons are disabled, and the controls remain hidden. With one slide, the controls appear, the arrows are disabled, and the sole indicator remains focusable but is marked aria-disabled="true". With several slides, both arrows remain available because navigation wraps rather than stopping at either end.

This is a toy on purpose, but the state rule scales: the active slide is the slide nearest the track’s logical starting edge. Button clicks request movement; geometry decides what actually became current.

BUTTONSWIPERESIZEREADTHE LAYOUTnearest winsINDICATORSTATUS
Every input is reconciled through the visible layout.

The resize listener uses the same synchronization path. It does not move to a cached offset, so a narrower container cannot leave the indicators describing the old position.

Make Motion and Controls Accessible

ARIA names the component and its parts, but it does not repair missing semantics or broken interaction. The HTML starts with an ordered list, figures, captions, and native buttons, then adds carousel-specific names.

The carousel container has a visible label through aria-labelledby. Its aria-roledescription="carousel" supplies the more specific description, while each native list item contains a group with aria-roledescription="slide" and a meaningful name such as Blue cove, slide 2 of 3.

Previous and next are buttons because they perform actions. Their accessible labels describe the result, and each aria-controls value points to the track it moves. The numbered indicator text remains visible, while its aria-label matches the accessible name of the slide it selects.

Normal document order already moves focus through the buttons. The script does not capture Tab, and clicking an indicator does not force focus into the newly visible slide.

Motion needs its own rule. The CSS media query removes smooth scrolling when prefers-reduced-motion: reduce matches, and the JavaScript makes the same choice for scrollIntoView(). CSS and JavaScript agree.

MOVEREDUCEDMOTION?YESINSTANTNOSMOOTHCHOSEN SLIDE
Reduced-motion preference changes how the slide moves, not where it goes.

The example does not autoplay. That is the safer default because the reader controls every change.

If you add automatic rotation, treat it as a separate optional feature with these rules:

  • Add a native button that stops and starts rotation.
  • Stop rotation when keyboard focus enters the carousel.
  • Stop rotation while the pointer hovers over the carousel.
  • Do not restart after either pause until the reader deliberately starts it again.

A timer must never become the active-state authority. Scrolling still determines the current slide, whether movement began with a timer, a button, or a swipe.

The focus rules here build on ordinary browser behavior. Styles and classes explains the DOM and CSS tools behind state-driven styling, while The Browser Platform develops these APIs as part of the complete browser model.

Optimize and Test the Finished Slider

Responsive images finish the implementation. Each img has intrinsic width and height, a srcset of available files, and a sizes hint describing its rendered width. The browser can reserve space before the file loads and select an appropriate source.

RENDEREDSLOT640 · 9601600BROWSERDECIDESPICK960w FILERESERVE16:9 BOX
Image metadata helps the browser choose a file and reserve space.

The first image uses loading="eager" because it is initially visible. Later images use loading="lazy", which lets their loading wait until they approach the viewport. Captions remain ordinary text below each image.

Now test the component through every input it claims to support:

  • Use Tab to reach every button, then activate each one with the keyboard.
  • Move past the first and last slides in both directions and confirm wrapping.
  • Swipe on a touch device and check that the indicator and status follow.
  • Scroll with a trackpad and drag the native scrollbar with a mouse.
  • Resize the viewport while the second slide is active.
  • Enable reduced motion and confirm that slide changes are instant.
  • Disable JavaScript and confirm that the focused track can be scrolled with the keyboard in every supported browser.
  • Test the layout in both left-to-right and right-to-left directions.
  • Test a slider with one slide and another with no slides.
  • Put two sliders on one page and confirm that each set of controls moves only its own track.
  • Check the carousel name, slide names, button labels, and status with a screen reader.

The no-JavaScript test catches the architectural mistake first. If the images disappear with the script, the component is not progressively enhanced. If a swipe leaves the wrong indicator selected, state is still tied to buttons instead of layout.

For another small project built around visible state, JavaScript Counter: Build One Step by Step follows the same pattern of one state source and several synchronized outputs.

Frequently asked questions

How do you make an image slider in JavaScript?
Create a horizontally scrollable list of slides with CSS Scroll Snap, then use JavaScript buttons to call scrollIntoView on the previous or next slide. Derive the active index from the slide nearest the viewport so indicators stay synchronized after button clicks, swipes, and trackpad scrolling.
How do you make a JavaScript image slider accessible?
Use native buttons, give the carousel and slides accessible names, preserve visible focus styles, and announce the current slide with a polite live region. Keep the content usable without JavaScript and remove smooth motion when prefers-reduced-motion is enabled.
How can an image slider wrap from the last slide to the first?
Wrap the requested index with modular arithmetic before moving. The expression ((index % count) + count) % count handles movement in both directions, including moving backward from index 0.
How do slider indicators stay synchronized after a swipe?
Listen for the track's scroll event and compare each slide's position with the track's logical starting edge. Update the current index and indicators from the nearest slide, then update the status when scrolling finishes.
Should an image carousel autoplay?
Autoplay is optional and is off in this example. If you add it, provide a stop and start button, stop rotation when focus enters or the pointer hovers over the carousel, and restart only after the reader deliberately starts it again.