<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
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.