An elliptical image carousel for portfolios, visual archives, campaign pages, moodboards, and brand-led sections where the collection should feel spatial, light, and explorable.

Ellipse Carousel turns a group of cards into an orbital composition.
Cards sit around an elliptical path, giving the section a sense of distribution rather than linear progression. The centre can hold a brand word, title, product name, or core message while the surrounding cards move as a connected visual field. The result feels airy, editorial, and gallery-like.
Use Ellipse Carousel when the goal is not just to move from slide to slide, but to create a spatial relationship between the central idea and the surrounding visuals. It works for creative portfolios, photography sets, campaign assets, travel and lifestyle galleries, brand moodboards, studio pages, and product-world presentations.
The page job is atmosphere and orientation. The visitor should feel the collection moving around a clear centre, not floating randomly across the screen. Ellipse motion works when the layout feels intentional. Without that centre, it is just scattered cards doing cardio.
npx hyperiux add ellipse-carouselimport EllipseCarousel from '@/components/effects/ellipse-carousel'
const Page = () => {
return (
<>
<EllipseCarousel/>
</>
)
}
export default Page
// Built using Hyperiux Vault: https://vault.hyperiux.com
import EllipseCarouselComp from "./EllipseCarousel";
const defaultItems = [
{ bgColor: "#f5f5f2", textColor: "#111111", title: "↑" },
{
src: "https://images.unsplash.com/photo-1500534623283-312aade485b7?w=600&h=900&fit=crop",
alt: "Coastal landscape painting",
},
{
src: "https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=600&h=900&fit=crop",
alt: "Studio portrait",
},
{ bgColor: "#ffffff", textColor: "#111111", title: "M" },
{ bgColor: "#0a0a0a", textColor: "#ffffff", title: "⌒", subtitle: "Nike" },
{ bgColor: "#f2c94c", textColor: "#111111", title: "Parks" },
{
src: "https://images.unsplash.com/photo-1519681393784-d120267933ba?w=600&h=900&fit=crop",
alt: "Mountain range",
},
{ bgColor: "#111111", textColor: "#ffffff", title: "Ext" },
{
src: "https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=600&h=900&fit=crop",
alt: "Night sky",
},
{ bgColor: "#dd4b39", textColor: "#ffffff", title: "ÖH" },
{
src: "https://images.unsplash.com/photo-1506863530036-1efeddceb993?w=600&h=900&fit=crop",
alt: "Profile portrait",
},
{ bgColor: "#2b3a67", textColor: "#ffffff", title: "SSSIII" },
];
const EllipseCarousel = ({ items = defaultItems, centerLabel = "HYPERIUX", showCenterLabel = true, ...rest }) => {
return (<EllipseCarouselComp items={items} centerLabel={centerLabel} showCenterLabel={showCenterLabel} {...rest}/>);
};
export default EllipseCarousel;
// Built using Hyperiux Vault: https://vault.hyperiux.com
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import Image from "next/image";
import { gsap } from "gsap";
import { usePrefersReducedMotion } from "@/lib/motion";
// Mirrors Tailwind's `max-md:` breakpoint (max-width: 767px).
const MOBILE_BREAKPOINT = 768;
const MOBILE_RADIUS_X_SCALE = 1.45;
const MOBILE_RADIUS_Y_SCALE = 0.82;
const MOBILE_CARD_SCALE = 0.75;
const EllipseCarousel = ({ items = [], backgroundColor = "#eeeeee", centerLabel, showCenterLabel = true, cardWidth = 130, cardHeight = 180, cardAspect = 0.9, minScale = 0.2, radiusXRatio = 0.25, radiusYRatio = 0.36, autoPlay = true, holdDuration = 0.4, stepDuration = 0.4, stepEase = "power2.inOut", pauseOnHover = true, draggable = true, dragSensitivity = 1, className = "", }) => {
const stageRef = useRef(null);
const cardRefs = useRef([]);
const reducedMotion = usePrefersReducedMotion();
const [isMobile, setIsMobile] = useState(false);
const total = items.length;
// Radians. Grows/shrinks without bound; per-card angle wraps naturally
// since it only ever feeds sin/cos.
const rotationRef = useRef(0);
const hoveredRef = useRef(false);
const hoverCountRef = useRef(0);
const draggingRef = useRef(false);
const lastAngleRef = useRef(0);
const dragOriginRef = useRef({ left: 0, top: 0 });
const settleTweenRef = useRef(null);
const autoplayTweenRef = useRef(null);
const autoplayDelayRef = useRef(null);
const geometryRef = useRef({
cx: 0,
cy: 0,
radiusX: 320,
radiusY: 260,
cardW: cardWidth,
cardH: cardHeight ?? cardWidth / cardAspect,
});
useEffect(() => {
const updateIsMobile = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
updateIsMobile();
window.addEventListener("resize", updateIsMobile);
return () => window.removeEventListener("resize", updateIsMobile);
}, []);
const measure = useCallback(() => {
const stage = stageRef.current;
if (!stage)
return;
const width = stage.offsetWidth;
const height = stage.offsetHeight;
const cardScale = isMobile ? MOBILE_CARD_SCALE : 1;
const radiusXScale = isMobile ? MOBILE_RADIUS_X_SCALE : 1;
const radiusYScale = isMobile ? MOBILE_RADIUS_Y_SCALE : 1;
geometryRef.current = {
cx: width / 2,
cy: height / 2,
radiusX: width * radiusXRatio * radiusXScale,
radiusY: height * radiusYRatio * radiusYScale,
cardW: cardWidth * cardScale,
cardH: (cardHeight ?? cardWidth / cardAspect) * cardScale,
};
}, [cardAspect, cardHeight, cardWidth, isMobile, radiusXRatio, radiusYRatio]);
useLayoutEffect(() => {
measure();
const stage = stageRef.current;
if (!stage || typeof ResizeObserver === "undefined") {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}
const observer = new ResizeObserver(measure);
observer.observe(stage);
return () => observer.disconnect();
}, [measure]);
// One pass around the ring: even angular spacing, position on the
// ellipse, and a size that peaks at the rightmost point (theta = 0) and
// bottoms out at the leftmost (theta = pi) — z-index rides the same
// curve, so the biggest card is always the one stacked on top.
const render = useCallback(() => {
if (!total)
return;
const { cx, cy, radiusX, radiusY, cardW, cardH } = geometryRef.current;
const rotation = rotationRef.current;
const step = (Math.PI * 2) / total;
for (let i = 0; i < total; i += 1) {
const card = cardRefs.current[i];
if (!card)
continue;
const theta = i * step + rotation;
const cosT = Math.cos(theta);
const sinT = Math.sin(theta);
const scale = minScale + (1 - minScale) * ((cosT + 1) / 2);
const w = cardW * scale;
const h = cardH * scale;
const x = cx + radiusX * cosT - w / 2;
const y = cy + radiusY * sinT - h / 2;
card.style.width = `${w}px`;
card.style.height = `${h}px`;
card.style.transform = `translate3d(${x}px, ${y}px, 0)`;
card.style.zIndex = `${Math.round(scale * 1000)}`;
}
}, [minScale, total]);
const stopAutoplay = useCallback(() => {
autoplayDelayRef.current?.kill();
autoplayTweenRef.current?.kill();
autoplayDelayRef.current = null;
autoplayTweenRef.current = null;
}, []);
// Hold on the active card, then snap the ring forward by exactly one slot
// so the next card lands dead-centre — a beat, not a drift.
const scheduleNextStep = useCallback(() => {
if (!autoPlay || reducedMotion || !total)
return;
autoplayDelayRef.current = gsap.delayedCall(holdDuration, () => {
if (draggingRef.current || (pauseOnHover && hoveredRef.current)) {
scheduleNextStep();
return;
}
const step = (Math.PI * 2) / total;
const state = { r: rotationRef.current };
autoplayTweenRef.current = gsap.to(state, {
r: state.r - step,
duration: stepDuration,
ease: stepEase,
onUpdate: () => {
rotationRef.current = state.r;
render();
},
onComplete: scheduleNextStep,
});
});
}, [autoPlay, holdDuration, pauseOnHover, reducedMotion, render, stepDuration, stepEase, total]);
useEffect(() => {
render();
scheduleNextStep();
return () => stopAutoplay();
}, [render, scheduleNextStep, stopAutoplay]);
// Grab-and-spin: 1:1 pointer tracking while held, then a short eased tween
// settles the ring onto whichever card ended up nearest the active slot —
// same snap the auto-advance uses, just released from a hand-picked spot.
useEffect(() => {
const stage = stageRef.current;
if (!stage || !draggable || !total)
return;
// Angle of the pointer around the ellipse centre, in the same terms as
// a card's theta (cx/cy/radiusX/radiusY), so tracking it 1:1 keeps
// whatever the pointer grabbed under the pointer — no matter which side
// of the ring it's on. A flat pixels-to-radians drag looks fine near the
// front card but visibly reverses direction once the grabbed point is
// past the top/bottom of the ellipse, since horizontal pixel motion
// there maps onto mostly-vertical arc motion.
const pointerAngle = (e) => {
const { cx, cy, radiusX, radiusY } = geometryRef.current;
const rect = dragOriginRef.current;
const localX = e.clientX - rect.left;
const localY = e.clientY - rect.top;
return Math.atan2((localY - cy) / radiusY, (localX - cx) / radiusX);
};
const onPointerDown = (e) => {
if (e.button !== 0 && e.pointerType === "mouse")
return;
stopAutoplay();
settleTweenRef.current?.kill();
settleTweenRef.current = null;
draggingRef.current = true;
const rect = stage.getBoundingClientRect();
dragOriginRef.current = { left: rect.left, top: rect.top };
lastAngleRef.current = pointerAngle(e);
stage.setPointerCapture(e.pointerId);
stage.style.cursor = "grabbing";
};
const onPointerMove = (e) => {
if (!draggingRef.current)
return;
const angle = pointerAngle(e);
let delta = angle - lastAngleRef.current;
delta = ((delta + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
lastAngleRef.current = angle;
rotationRef.current += delta * dragSensitivity;
render();
};
const endDrag = (e) => {
if (!draggingRef.current)
return;
draggingRef.current = false;
stage.style.cursor = "grab";
if (stage.hasPointerCapture(e.pointerId))
stage.releasePointerCapture(e.pointerId);
const step = (Math.PI * 2) / total;
const target = Math.round(rotationRef.current / step) * step;
const state = { r: rotationRef.current };
if (reducedMotion) {
rotationRef.current = target;
render();
scheduleNextStep();
return;
}
settleTweenRef.current = gsap.to(state, {
r: target,
duration: 0.5,
ease: "power3.out",
onUpdate: () => {
rotationRef.current = state.r;
render();
},
onComplete: () => {
settleTweenRef.current = null;
scheduleNextStep();
},
});
};
stage.addEventListener("pointerdown", onPointerDown);
stage.addEventListener("pointermove", onPointerMove);
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
return () => {
stage.removeEventListener("pointerdown", onPointerDown);
stage.removeEventListener("pointermove", onPointerMove);
stage.removeEventListener("pointerup", endDrag);
stage.removeEventListener("pointercancel", endDrag);
settleTweenRef.current?.kill();
};
}, [dragSensitivity, draggable, reducedMotion, render, scheduleNextStep, stopAutoplay, total]);
// Autoplay should only pause for a card actually under the pointer, not
// the empty space around/between cards on the stage — so hover is tracked
// per card (via a count, since only the topmost overlapping card gets the
// events) rather than on the stage as a whole.
const onCardPointerEnter = useCallback(() => {
hoverCountRef.current += 1;
hoveredRef.current = true;
}, []);
const onCardPointerLeave = useCallback(() => {
hoverCountRef.current = Math.max(0, hoverCountRef.current - 1);
hoveredRef.current = hoverCountRef.current > 0;
}, []);
if (!total)
return null;
return (<section className={`relative h-dvh w-full overflow-hidden select-none ${className}`} style={{ backgroundColor }}>
<div ref={stageRef} tabIndex={0} role="region" aria-label="Circular card carousel" className={`absolute inset-0 touch-pan-y outline-none ${draggable ? "cursor-grab" : ""}`}>
{showCenterLabel && centerLabel ? (<div className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center">
<span className="text-[3.4vw] font-medium tracking-tight text-black/90">{centerLabel}</span>
</div>) : null}
{items.map((item, i) => (<div key={i} ref={(el) => {
cardRefs.current[i] = el;
}} onPointerEnter={onCardPointerEnter} onPointerLeave={onCardPointerLeave} className="absolute left-0 top-0 overflow-hidden shadow-xl will-change-transform">
{item.src ? (<Image src={item.src} alt={item.alt ?? ""} fill sizes={`${Math.round(cardWidth * (isMobile ? MOBILE_CARD_SCALE : 1))}px`} draggable={false} className="pointer-events-none select-none object-cover"/>) : (<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-3 text-center" style={{ backgroundColor: item.bgColor ?? "#111111", color: item.textColor ?? "#ffffff" }}>
{item.title ? <span className="text-2xl font-black leading-none">{item.title}</span> : null}
{item.subtitle ? (<span className="text-[0.6rem] uppercase tracking-[0.2em] opacity-70">{item.subtitle}</span>) : null}
</div>)}
</div>))}
</div>
</section>);
};
export default EllipseCarousel;
A studio homepage can use Ellipse Carousel to surround the brand name with selected project stills, references, and campaign fragments. The central word remains still while the surrounding cards suggest range, taste, and momentum.
The outcome is brand atmosphere. The visitor gets a sense of the studio’s visual world before reading the service details.
A travel, fashion, or photography page can use the same component to create a lightweight visual archive where images feel connected through motion rather than locked into a conventional grid.
Not for dense catalogues, search-driven galleries, pricing content, documentation, comparison tables, forms, or content that needs linear reading.
Not for image sets with no visual relationship. Ellipse Carousel can arrange a collection beautifully, but it cannot create curation where none exists.
Not for sections where the centre message must compete with large moving cards.
Control the number of cards, optimise images, set fixed card dimensions, and animate transform and opacity rather than layout properties. Recalculate positions only on meaningful resize changes.
Avoid large shadows, oversized images, and constant animation loops when the carousel is outside the viewport. The field should feel light, not like every card is carrying a rendering invoice.
Preserve a logical reading order even when cards are visually distributed around the ellipse. Use real controls if the carousel is interactive, and provide accessible labels for image cards, active states, or view changes where applicable.
On mobile, simplify the ellipse into a stacked, swipeable, or flatter orbital layout. Keep the central message visible and avoid placing tiny cards too close to screen edges. For reduced motion, render a static ellipse, a simple grid, or a minimal fade between states.
| Prop | Type | Default | Description |
|---|---|---|---|
backgroundColor | string | #eeeeee | Background fill behind the carousel stage. |
centerLabel | string | Ellipse Carousel | Text shown at the center of the ellipse when enabled. |
showCenterLabel | boolean | true | Shows or hides the center label. |
cardWidth | number | 130 | Card width at the largest active position, in pixels. |
cardHeight | number | 180 | Card height at the largest active position, in pixels. |
minScale | number | 0.2 | Minimum scale applied to the farthest cards on the ellipse. |
radiusXRatio | number | 0.25 | Horizontal ellipse radius as a fraction of the stage width. |
radiusYRatio | number | 0.36 | Vertical ellipse radius as a fraction of the stage height. |
autoPlay | boolean | true | Automatically advances the carousel by one card at a time. |
holdDuration | number | 1 | Seconds to hold on the active card before advancing. |
stepDuration | number | 0.7 | Seconds for the snap transition between cards. |
pauseOnHover | boolean | true | Pauses autoplay while hovering a card. |
draggable | boolean | true | Allows users to drag the ellipse by hand. |
dragSensitivity | number | 1 | Multiplier on the carousel's angular drag response. |
Ellipse Carousel is a React carousel or visual layout effect that positions image cards around an elliptical path or orbital field.
Use it for portfolios, moodboards, image archives, campaign pages, photography sets, lifestyle galleries, and brand sections where visuals should surround a central idea.
No. Orbit Flip Slider focuses on switching between multiple layout modes. Ellipse Carousel focuses on one elliptical spatial arrangement around a central point.
Yes. The centre can hold a brand name, title, product name, section heading, or short message. Keep it stable and readable.
A controlled set works best. Too many cards can make the ellipse feel cluttered, especially on smaller screens.
Use meaningful alt text for content images, hide decorative cards where appropriate, preserve logical reading order, and provide keyboard-accessible controls when interaction is available.
Reduced motion should remove orbit travel, repeated floating, and depth movement. Use a static ellipse, simple grid, or minimal fade.
Yes. A custom version should define ellipse geometry, card size, image ratios, central content behavior, motion rhythm, mobile fallback, accessibility states, reduced-motion handling, source handoff, and implementation notes.
Need a custom effect? Tell us what to create.