<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@500..700&display=swap" rel="stylesheet">

<div class="container">
  <div class="halo-slider ring">
    <div id="ring" class="halo-slider--ring">
      <div class="halo-slider--slide">
        <img src="https://assets.codepen.io/3735/bear.svg" alt="Bear" />
      </div>
      <div class="halo-slider--slide">
        <img src="https://assets.codepen.io/3735/bird.svg" alt="Bird" />
      </div>
      <div class="halo-slider--slide">
        <img src="https://assets.codepen.io/3735/cup.svg" alt="Cup" />
      </div>
      <div class="halo-slider--slide">
        <img src="https://assets.codepen.io/3735/dog.svg" alt="Dog" />
      </div>
      <div class="halo-slider--slide">
        <img src="https://assets.codepen.io/3735/sun.svg" alt="Sun" />
      </div>
      <div class="halo-slider--slide">
        <img src="https://assets.codepen.io/3735/moon.svg" alt="Moon" />
      </div>
      <div class="halo-slider--slide">
        <img src="https://assets.codepen.io/3735/cat.svg" alt="Cat" />
      </div>
    </div>

    <div class="halo-slider--controls">
      <div class="halo-slider--buttons">
        <button class="halo-slider--prev"></button>
        <button class="halo-slider--next"></button>
      </div>
      
      <div class="halo-slider--info-boxes mt-24">
          <div class="halo-slider--info-box">
            <div class="halo-slider--info-box-title">Bear</div>
            <p>The big, fuzzy bear waved hello as he walked through the forest.</p>
          </div>
        
          <div class="halo-slider--info-box">
            <div class="halo-slider--info-box-title">Bird</div>
            <p>The little bird sang a sweet song while flying high in the sky.</p>
          </div>
        
          <div class="halo-slider--info-box">
            <div class="halo-slider--info-box-title">Cup</div>
            <p>Sophie filled her cup with warm, chocolaty milk and smiled.</p>
          </div>
        
          <div class="halo-slider--info-box">
            <div class="halo-slider--info-box-title">Dog</div>
            <p>The happy dog wagged its tail, ready for a game of fetch.</p>
          </div>
        
          <div class="halo-slider--info-box">
            <div class="halo-slider--info-box-title">Sun</div>
            <p>The sun peeked over the hill, and made the flowers dance.</p>
          </div>
        
          <div class="halo-slider--info-box">
            <div class="halo-slider--info-box-title">Moon</div>
            <p>At night, the moon glowed softly, lighting up the path for the sleepy owl.</p>
          </div>
        
          <div class="halo-slider--info-box">
            <div class="halo-slider--info-box-title">Cat</div>
            <p>The curious cat chased its tail, spinning in circles until it tumbled over.</p>
          </div>
        </div>
    </div>
  </div>
</div>
body {
  background: plum url(https://assets.codepen.io/3735/halo-bg.webp) no-repeat center center;
  background-size: cover;
  font-family: "Quicksand", sans-serif;
  font-optical-sizing: auto;
  font-weight: 500;
  font-style: normal;
  color: #4A148C;
}

p {
  margin: .5em 0;
}

.container {
  margin: 0 auto;
  padding: 60px 90px;
  max-width: 100%;
  width: 600px;
  overflow: hidden;
}

@media (max-width: 1260px) {
  .container {
    width: 100%;
    max-width: 600px;
  }
}

.halo-slider--ring {
  width: 100%;
  height: 320px;
  max-height: 200px;
  margin: auto;
  position: relative;
}

.halo-slider--slide {
  position: absolute;
  top: var(--ypos, 0);
  left: var(--xpos, 0);
  width: 160px;
  height: 160px;
  transform: translate(-50%, -50%) scale(var(--scale, 1));
  z-index: var(--z, 1);
}

.halo-slider--slide img {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: contain;
  object-position: center;
  pointer-events: none;
}

.halo-slider--controls {
    z-index: 2;
    position: relative;
    margin-top: 120px;
    text-align: center;
}

 .halo-slider--info-boxes {
   position: relative;
}

.halo-slider--info-box {
  max-width: 650px;
  margin: 0 auto;
  opacity: 0;
  transition: opacity 0.3s ease;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  font-size: 32px;
  line-height: 1.4;
  text-wrap: balance;
}

.halo-slider--info-box-title {
  font-size: 48px;
  font-weight: 700;
  margin: 0;
  
}

.halo-slider--info-box.is-active {
  opacity: 1;
  position: static;
}

.halo-slider--prev,
.halo-slider--next {
  font-family: "Quicksand", sans-serif;
  background: none;
  border: none;
  font-size: 64px;
  font-weight: 700;
  color: #E64A19;
  cursor: pointer;
  padding: 10px;
}

.halo-slider--prev {
  transform: scaleX(-100%);
}

@media (max-width: 768px) {
  .container {
    padding-left: 0;
    padding-right: 0;
  }

  .halo-slider--ring {
    width: calc(100% - 60px);
  }
  
  .halo-slider--controls {
     padding: 0 30px 0;
    margin-top: 30px;
  }
  
  .halo-slider--slide {
    width: 120px;
    height; 120px;
  }
  
  .halo-slider--info-box {
    font-size: 20px;
  }

  .halo-slider--info-box-title {
    font-size: 32px;
  }

}
class HaloSlide {
  slideEl: HTMLElement;
  currentAngle: number = 0;
  targetAngle: number = 0;
  xPos: number = 0;
  yPos: number = 0;

  constructor(slideEl: HTMLElement) {
    if (!slideEl) {
      throw new Error("Element not found");
    }

    this.slideEl = slideEl;
  }

  updatePosition(
    xpos: number,
    ypos: number,
    angle: number,
    isActive: boolean,
    updateTarget = false,
  ) {
    this.xPos = xpos;
    this.yPos = ypos;
    this.currentAngle = angle;
    this.slideEl.style.setProperty("--xpos", `${xpos}px`);
    this.slideEl.style.setProperty("--ypos", `${ypos}px`);
    this.slideEl.style.setProperty("--z", Math.ceil(ypos));

    if (updateTarget) {
      this.targetAngle = angle;
    }
    
    if (isActive) {
      this.slideEl.classList.add("is-active");
    } else {
      this.slideEl.classList.remove("is-active");
    }
  }

  updateScale(radius: number) {
    let newScale = 0.35 + (this.yPos / (radius * 2)) * 0.65;
    this.slideEl.style.setProperty("--scale", `${newScale}`);
  }
}

class HaloSlider {
  sliderEl: HTMLElement | null = null;
  ringEl: HTMLElement | null = null;
  radiusX: number = 0;
  radiusY: number = 0;
  slides: HaloSlide[] = [];
  offset: number = 0;
  currentRotation: number = 0;
  currentSlideIndex: number = 0;
  lastTick: number = 0;
  currentAngle: number = 0;
  resizeObserver: ResizeObserver | null = null;
  touchStartX: number = 0;
  touchEndX: number = 0;
  isRotating: boolean = false;

  /**
   * Creates an instance of HaloSlider.
   * @param {string} selector - The CSS selector for the slider element.
   */
  constructor(el: HTMLElement) {
    this.sliderEl = el;

    if (!this.sliderEl) {
      throw new Error("Element not found");
    }

    this.ringEl = this.sliderEl.querySelector(".halo-slider--ring");

    this.radiusX = this.ringEl ? this.ringEl.clientWidth / 2 : 0;
    this.radiusY = this.ringEl ? this.ringEl.clientHeight / 2 : 0;

    this.createSlides();
    
    this.infoBoxes = this.sliderEl?.querySelectorAll(
      ".halo-slider--info-box",
    );

    this.setInfoBoxStatus();

    const nextBtn = this.sliderEl.querySelector(".halo-slider--next");
    const prevBtn = this.sliderEl.querySelector(".halo-slider--prev");

    nextBtn?.addEventListener("click", this.onClickNext.bind(this));
    prevBtn?.addEventListener("click", this.onClickPrev.bind(this));
    
    this.sliderEl.addEventListener("touchstart", (event) => {
      this.touchStartX = event.touches[0].clientX;
    });

    this.sliderEl.addEventListener("touchend", (event) => {
      this.touchEndX = event.changedTouches[0].clientX;

      const threshold = 30; // Minimum distance for a swipe

      if (this.touchEndX < this.touchStartX - threshold) {
        // Swiped left
        this.onClickNext();
      } else if (this.touchEndX > this.touchStartX + threshold) {
        // Swiped right
        this.onClickPrev();
      }
    });

    this.setupResizeObserver();
  }

  setupResizeObserver() {
    if (this.ringEl) {
      this.resizeObserver = new ResizeObserver((entries) => {
        for (const entry of entries) {
          this.radiusX = entry.contentRect.width / 2;
          this.radiusY = entry.contentRect.height / 2;
          this.slides.forEach((slide, index) => {
            const [xpos, ypos] = this.getCirclePosition(slide.currentAngle);
            slide.updatePosition(
              xpos,
              ypos,
              slide.currentAngle,
              index === this.currentSlideIndex
            );
            slide.updateScale(this.radiusY);
          });
        }
      });

      this.resizeObserver.observe(this.ringEl);
    }
  }

  /**
   * Creates the slides and positions them in a circular layout.
   */
  createSlides() {
    const slides = this.sliderEl?.querySelectorAll(".halo-slider--slide") || [];
    this.offset = this.getOffsetDegrees(slides.length);

    slides.forEach((slideEl, index) => {
      const angle = (0 - index) * this.offset;
      const [xpos, ypos] = this.getCirclePosition(angle);

      const haloSlide = new HaloSlide(slideEl as HTMLElement);
      haloSlide.updatePosition(
        xpos,
        ypos,
        angle,
        index === this.currentSlideIndex,
        true
      );
      haloSlide.updateScale(this.radiusY);

      slideEl.addEventListener("click", this.onClickSlide.bind(this));

      this.slides.push(haloSlide);
    });
  }

  /**
   * Calculates the offset degrees based on the number of slides.
   * @param {number} count - The number of slides.
   * @returns {number} The offset degrees.
   */
  getOffsetDegrees(count: number) {
    return 360 / count;
  }

  getPointOnEllipse(
    centerX: number,
    centerY: number,
    radiusX: number,
    radiusY: number,
    angle: number,
  ) {
    const x = centerX + radiusX * Math.cos(angle);
    const y = centerY + radiusY * Math.sin(angle);
    return [x, y];
  }

  degreesToRadians(degrees: number) {
    return degrees / 57.2958;
  }

  /**
   * Calculates the position of a slide in the circular layout.
   * @param {number} index - The index of the slide.
   * @returns {[number, number]} The x and y positions of the slide.
   */
  getCirclePosition(degree: number) {
    return this.getPointOnEllipse(
      this.radiusX,
      this.radiusY,
      this.radiusX,
      this.radiusY,
      this.degreesToRadians(degree + 90),
    );
  }

  /**
   * Gets the index of a slide element.
   * @param {HTMLElement} slideEl - The slide element.
   * @returns {number} The index of the slide element.
   */
  getSlideIndex(slideEl: HTMLElement) {
    const slide = this.slides.find((slide) => slide.slideEl === slideEl);

    if (slide) {
      return this.slides.indexOf(slide);
    }

    return -1;
  }

  /**
   * Handles the click event on a slide.
   * @param {Event} e - The click event.
   */
  onClickSlide(e: Event) {
    const slideEl = e?.target as HTMLElement;
    if (!slideEl) return;

    const newIndex = this.getSlideIndex(slideEl);

    this.rotateSlider(newIndex);
  }

  /**
   * Handles the click event on the next button.
   * @param {Event} e - The click event.
   */
  onClickNext(e: Event) {
    e?.preventDefault();
    let index = this.currentSlideIndex + 1;
    index = index >= this.slides.length ? 0 : index;
    this.rotateSlider(index);
  }

  /**
   * Handles the click event on the previous button.
   * @param {Event} e - The click event.
   */
  onClickPrev(e: Event) {
    e?.preventDefault();
    let index = this.currentSlideIndex - 1;
    index = index < 0 ? this.slides.length - 1 : index;
    this.rotateSlider(index);
  }

  /**
   * Rotates the slider to the specified slide index.
   * @param {number} newIndex - The new slide index.
   */
  rotateSlider(newIndex: number) {
    if(this.isRotating) {
      return;
    }
    
    let indexOffset = newIndex - this.currentSlideIndex;
    indexOffset =
      indexOffset < 0 ? indexOffset + this.slides.length : indexOffset;

    this.currentSlideIndex = newIndex;

    let degree = indexOffset * this.offset;
    degree = degree > 180 ? degree - 360 : degree;
    
    this.setInfoBoxStatus();

    this.startAnimation(degree);
  }

  startAnimation(offset = 0) {
    this.isRotating = true;
    
    this.slides.forEach((slide) => {
      slide.targetAngle = slide.currentAngle + offset;
    });
    window.requestAnimationFrame(this.doAnimation.bind(this));
  }

  doAnimation(timestamp: number) {
    if (this.lastTick === 0) {
      this.lastTick = timestamp;
    }
    const elapsed = timestamp - this.lastTick;
    let stillAnimating = false;

    this.slides.forEach((slide, index) => {
      let angle = Math.min(
        slide.currentAngle + 0.15 * elapsed,
        slide.targetAngle,
      );

      if (slide.targetAngle < slide.currentAngle) {
        angle = Math.max(
          slide.currentAngle - 0.15 * elapsed,
          slide.targetAngle,
        );
      }

      const [xpos, ypos] = this.getPointOnEllipse(
        this.radiusX,
        this.radiusY,
        this.radiusX,
        this.radiusY,
        this.degreesToRadians(angle + 90),
      );
      slide.updatePosition(
        xpos,
        ypos,
        angle,
        index === this.currentSlideIndex
      );
      slide.updateScale(this.radiusY);

      if (slide.currentAngle !== slide.targetAngle) {
        stillAnimating = true;
      }
    });

    this.lastTick = timestamp;

    if (stillAnimating) {
      requestAnimationFrame(this.doAnimation.bind(this));
    } else {
      this.lastTick = 0;
      this.isRotating = false;
    }
  }
  
  setInfoBoxStatus() {
    this.infoBoxes.forEach((infobox, index) => {
      if (index === this.currentSlideIndex) {
        infobox.classList.add("is-active");
      } else {
        infobox.classList.remove("is-active");
      }
    });
  }
}

export function initHaloSlider(selector = ".halo-slider") {
    const sliders = document.querySelectorAll(".halo-slider");
    sliders.forEach((slider) => {
        new HaloSlider(slider);
    });
}

export default HaloSlider;

initHaloSlider();
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.