Auto Image Cycle (Slideshow)

A pure-CSS-transition slideshow that automatically cycles through images at a configurable interval. The script manages three states — active, previous, and not-active — as data attributes, enabling a smooth cross-fade. Uses IntersectionObserver to pause cycling when the component is off-screen.

slideshowautocyclecss transitionintersection observer

Code

HTML
html
<div class="image-cycle-collection">
  <div class="image-cycle-collection__before"></div>
  <div class="image-cycle-collection__list" data-image-cycle="2.5">
    <div class="image-cycle-collection__img" data-image-cycle-item="" class="image-cycle-collection__item">
      <img src="https://cdn.prod.website-files.com/679e2a340ce9c67cecbca3ad/679e2c81170b6a90046718a2_image-1.webp" loading="lazy" alt="">
    </div>
    <div class="image-cycle-collection__img" data-image-cycle-item="" class="image-cycle-collection__item">
      <img src="https://cdn.prod.website-files.com/679e2a340ce9c67cecbca3ad/679e2c80ab2f91466875df0b_image-2.webp" loading="lazy" alt="">
    </div>
    <div class="image-cycle-collection__img" data-image-cycle-item="" class="image-cycle-collection__item">
      <img src="https://cdn.prod.website-files.com/679e2a340ce9c67cecbca3ad/679e2c80f4b142f1a685a2ee_image-3.webp" loading="lazy" alt="">
    </div>
    <div class="image-cycle-collection__img" data-image-cycle-item="" class="image-cycle-collection__item">
      <img src="https://cdn.prod.website-files.com/679e2a340ce9c67cecbca3ad/679e2c801b8a79e8393b622b_image-4.webp" loading="lazy" alt="">
    </div>
    <div class="image-cycle-collection__img" data-image-cycle-item="" class="image-cycle-collection__item">
      <img src="https://cdn.prod.website-files.com/679e2a340ce9c67cecbca3ad/679e2c803f7f3ff9ad22d21b_image-5.webp" loading="lazy" alt="">
    </div>
  </div>
</div>
CSS
css
.image-cycle-collection {
  width: min(95vw, 60em);
  position: relative;
}

.image-cycle-collection__before {
  padding-top: 66.666%;
}

.image-cycle-collection__list {
  z-index: 0;
  border-radius: 2em;
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
}

.image-cycle-collection__item {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

[data-image-cycle-item="active"] {
  transition: opacity 0.4s ease 0s, visibility 0s ease 0s;
  opacity: 1;
  visibility: visible;
  z-index: 3;
}

[data-image-cycle-item="previous"] {
  transition: opacity 0.4s ease 0.4s, visibility 0s ease 0.4s;
  opacity: 0;
  visibility: visible;
  z-index: 2;
}

[data-image-cycle-item="not-active"] {
  opacity: 0;
  visibility: hidden;
  z-index: 1;
}

.image-cycle-collection__img {
  object-fit: cover;
  width: 100%;
  height: 100%;
  position: absolute;
}

@media screen and (max-width: 767px) {
  .image-cycle-collection__list {
    border-radius: 1em;
  }
}
JavaScript
javascript
function initImageCycle() {
  document.querySelectorAll("[data-image-cycle]").forEach(cycleElement => {
    const items = cycleElement.querySelectorAll("[data-image-cycle-item]");
    if (items.length < 2) return;

    let currentIndex = 0;
    let intervalId;

    // Get optional custom duration (in seconds), fallback to 2000ms
    const attrValue = cycleElement.getAttribute("data-image-cycle");
    const duration = attrValue && !isNaN(attrValue) ? parseFloat(attrValue) * 1000 : 2000;
    const isTwoItems = items.length === 2;

    // Initial state
    items.forEach((item, i) => {
      item.setAttribute("data-image-cycle-item", i === 0 ? "active" : "not-active");
    });

    function cycleImages() {
      const prevIndex = currentIndex;
      currentIndex = (currentIndex + 1) % items.length;

      items[prevIndex].setAttribute("data-image-cycle-item", "previous");

      if (!isTwoItems) {
        setTimeout(() => {
          items[prevIndex].setAttribute("data-image-cycle-item", "not-active");
        }, duration);
      }

      items[currentIndex].setAttribute("data-image-cycle-item", "active");
    }

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && !intervalId) {
        intervalId = setInterval(cycleImages, duration);
      } else {
        clearInterval(intervalId);
        intervalId = null;
      }
    }, { threshold: 0 });

    observer.observe(cycleElement);
  });
}

// Initialize Image Cycle
document.addEventListener('DOMContentLoaded', function() {
  initImageCycle();
});

Attributes

NameTypeDefaultDescription
data-image-cycleattribute (number, optional)Add to the list container to mark it as a cycle group. The optional value sets the interval in seconds. Omit the value or leave it empty to default to 2000 ms.
data-image-cycle-itemattribute (string, managed by JS)Added to each slide element. The script sets its value to "active", "previous", or "not-active" on every cycle tick. Use these attribute selectors in CSS to drive the transition.

Notes

  • No dependencies — no GSAP or external libraries required.
  • The IntersectionObserver pauses the interval when the component is scrolled out of view and restarts it when it re-enters, saving CPU on long pages.
  • The "previous" state keeps the outgoing slide visible during the opacity transition, ensuring a smooth cross-fade rather than a hard cut.
  • For exactly 2 images, the previous state is never cleared to not-active — this avoids a flash caused by the outgoing image disappearing too early.
  • Multiple [data-image-cycle] groups on the same page are fully independent — each has its own interval and observer.

Guide

Controlling the cycle speed

Pass a number (in seconds) directly on the [data-image-cycle] attribute. Each group can have its own speed independently.

<!-- Defaults to 2000ms -->
<div data-image-cycle>...</div>

<!-- Custom interval: 3 seconds -->
<div data-image-cycle="3">...</div>

Slide states

Each slide item receives one of three attribute values that you can target in CSS to animate transitions: "active" (currently visible), "previous" (fading out, kept visible briefly to allow the fade transition to complete), and "not-active" (hidden, no transition).

Aspect ratio

The .image-cycle-collection__before div uses a padding-top percentage to maintain a fixed aspect ratio without a fixed height — 66.666% produces a 3:2 ratio. Change this value to adjust the ratio without touching the absolute-positioned list.

Multiple groups on one page

Each [data-image-cycle] element gets its own interval and IntersectionObserver instance. Add as many groups as needed — they all run independently.