A 3D-feeling carousel for portfolios, campaign sections, product stories, and brand showcases where each slide should feel like a physical panel entering the scene.

Dimensional Switch Slider turns a standard card carousel into a spatial transition.
Each item sits as a focused visual panel, then switches through a perspective-led motion when the user moves forward or backward. The outgoing card tilts and rotates while the incoming card takes its place. The result gives each slide a stronger sense of arrival than a simple horizontal transition.
Use Dimensional Switch Slider when the content deserves focus and presentation: featured projects, visual services, campaign ideas, product modules, founder stories, case-study entries, and brand-led landing sections. It works best when every slide has one strong image, one clear title, and a reason to be treated like a standalone moment.
The page job is controlled attention. The slider should make the next item feel selected, not merely swapped. The motion should help the visitor understand that the panel has changed, while keeping the text readable, controls predictable, and layout stable.
npx hyperiux add dimensional-switch-sliderimport DimensionalSwitchSlider from '@/components/effects/dimensional-switch-slider'
const Page = () => {
return (
<>
<DimensionalSwitchSlider/>
</>
)
}
export default Page
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
import { ArrowLeft, ArrowRight } from "lucide-react";
import Image from "next/image";
gsap.registerPlugin(CustomEase);
const prefersReducedMotion = () => typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
const R2_BASE = "https://pub-8abee449136941f5b0a1cd2c014534e9.r2.dev/vault-listing-images/assets-images";
const ITEMS = [
{ image: `${R2_BASE}/h-06.jpg`, text: "Dimensional Switch" },
{ image: `${R2_BASE}/h-18.jpg`, text: "Hyperiux Vault" },
{ image: `${R2_BASE}/h-20.jpg`, text: "Motion Systems" },
{ image: `${R2_BASE}/h-11.jpg`, text: "Interaction Design" },
{ image: `${R2_BASE}/h-15.jpg`, text: "Source-First" },
];
const DEFAULT_EASE = "cubic-bezier(1, -0.001, 0.159, 0.838)";
const CUBIC_BEZIER_RE = /^cubic-bezier\(\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^)]+)\)$/;
function resolveEase(ease) {
const match = ease.match(CUBIC_BEZIER_RE);
if (!match)
return ease;
const id = `ease-${match.slice(1, 5).join("_").replace(/[^\d.-]/g, "n")}`;
if (!CustomEase.get(id)) {
CustomEase.create(id, match.slice(1, 5).join(","));
}
return id;
}
function toCssLength(value) {
return typeof value === "number" ? `${value}px` : value;
}
const EASE = "power4.inOut";
const DURATION = 0.9;
const TEXT_TRANSLATE_PERCENT = 40;
const TEXT_ROTATE_DEG = 45;
const OUTGOING_DURATION = DURATION * 0.45;
const VERTICAL_TEXT_TRANSLATE_PERCENT = 40;
const VERTICAL_TEXT_ROTATE_DEG = 45;
const VERTICAL_TEXT_ROTATE_REVERSED = true;
const VERTICAL_TEXT_TRANSLATE_REVERSED = true;
const VERTICAL_OUTGOING_DURATION = DURATION * 0.45;
const TEXT_Z = 60;
const DimensionalSwitchSlider = ({ infinite = true, ease = DEFAULT_EASE, textColor = "#ffffff", cardClassName = "max-md:w-[85vw]! max-md:h-[75vw]! max-[1024px]:w-[85vw]! max-[1024px]:h-[55vw]!", cardWidth = 680, cardHeight = 460, direction = "horizontal", textSize = 84, cardBorderRadius = 16, autoplay = true, autoplayDelay = 2600, } = {}) => {
const isVertical = direction === "vertical";
const flipAxis = isVertical ? "rotateX" : "rotateY";
const flipperRef = useRef(null);
const prevTextRef = useRef(null);
const nextTextRef = useRef(null);
const showingNextRef = useRef(false);
const isAnimatingRef = useRef(false);
const rotationRef = useRef(0);
const resolvedEase = useMemo(() => resolveEase(ease), [ease]);
const resolvedCardWidth = toCssLength(cardWidth);
const resolvedCardHeight = toCssLength(cardHeight);
const resolvedTextSize = toCssLength(textSize);
const resolvedCardBorderRadius = toCssLength(cardBorderRadius);
const [frontIndex, setFrontIndex] = useState(0);
const [backIndex, setBackIndex] = useState(1 % ITEMS.length);
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
gsap.set([prevTextRef.current, nextTextRef.current], { z: TEXT_Z });
}, []);
useEffect(() => {
gsap.killTweensOf([flipperRef.current, prevTextRef.current, nextTextRef.current]);
const wasShowingNext = showingNextRef.current;
rotationRef.current = wasShowingNext ? 180 : 0;
gsap.set(flipperRef.current, { rotateX: 0, rotateY: 0, [flipAxis]: rotationRef.current });
const visibleRef = wasShowingNext ? nextTextRef : prevTextRef;
const hiddenRef = wasShowingNext ? prevTextRef : nextTextRef;
gsap.set(visibleRef.current, { xPercent: 0, yPercent: 0, rotateX: 0, rotateY: 0, opacity: 1 });
gsap.set(hiddenRef.current, { xPercent: 0, yPercent: 0, rotateX: 0, rotateY: 0, opacity: 0 });
isAnimatingRef.current = false;
}, [direction, flipAxis]);
const flipTo = useCallback((direction, newIndex) => {
const goingNext = direction === "next";
if (isAnimatingRef.current)
return;
const wasShowingNext = showingNextRef.current;
flushSync(() => {
setCurrentIndex(newIndex);
if (wasShowingNext) {
setFrontIndex(newIndex);
}
else {
setBackIndex(newIndex);
}
});
showingNextRef.current = !wasShowingNext;
const outgoingRef = wasShowingNext ? nextTextRef : prevTextRef;
const incomingRef = wasShowingNext ? prevTextRef : nextTextRef;
const flipGoingNext = isVertical ? !goingNext : goingNext;
rotationRef.current += flipGoingNext ? 180 : -180;
const rotateValue = rotationRef.current;
if (prefersReducedMotion()) {
gsap.set(flipperRef.current, { [flipAxis]: rotateValue });
gsap.set(outgoingRef.current, {
xPercent: 0,
yPercent: 0,
rotateX: 0,
rotateY: 0,
opacity: 0,
});
gsap.set(incomingRef.current, {
xPercent: 0,
yPercent: 0,
rotateX: 0,
rotateY: 0,
opacity: 1,
});
isAnimatingRef.current = false;
return;
}
isAnimatingRef.current = true;
const tl = gsap.timeline({
defaults: { duration: DURATION, ease: EASE },
onComplete: () => {
isAnimatingRef.current = false;
},
});
// Card flip - untouched.
tl.to(flipperRef.current, { [flipAxis]: rotateValue, ease: resolvedEase, duration: 0.7 }, 0);
const textTl = gsap.timeline();
if (isVertical) {
const rotateGoingNext = VERTICAL_TEXT_ROTATE_REVERSED ? !goingNext : goingNext;
const translateGoingNext = VERTICAL_TEXT_TRANSLATE_REVERSED ? !goingNext : goingNext;
const outgoingEndRotate = rotateGoingNext
? VERTICAL_TEXT_ROTATE_DEG
: -VERTICAL_TEXT_ROTATE_DEG;
const outgoingEndY = translateGoingNext
? -VERTICAL_TEXT_TRANSLATE_PERCENT * 2
: VERTICAL_TEXT_TRANSLATE_PERCENT * 2;
const incomingStartRotate = rotateGoingNext
? -VERTICAL_TEXT_ROTATE_DEG
: VERTICAL_TEXT_ROTATE_DEG;
const incomingStartY = translateGoingNext
? VERTICAL_TEXT_TRANSLATE_PERCENT * 2
: -VERTICAL_TEXT_TRANSLATE_PERCENT * 2;
textTl.to(outgoingRef.current, {
yPercent: outgoingEndY,
rotateX: outgoingEndRotate,
duration: VERTICAL_OUTGOING_DURATION * 1.5,
ease: resolvedEase,
}, 0);
textTl.to(outgoingRef.current, { opacity: 0, delay: -0.3, duration: 0 });
textTl.fromTo(incomingRef.current, { yPercent: incomingStartY, rotateX: incomingStartRotate, opacity: 0 }, {
yPercent: 0,
rotateX: 0,
opacity: 1,
duration: VERTICAL_OUTGOING_DURATION * 1.5,
ease: resolvedEase,
}, 0.15);
}
else {
const outgoingEndRotate = goingNext ? TEXT_ROTATE_DEG : -TEXT_ROTATE_DEG;
const outgoingEndX = goingNext
? TEXT_TRANSLATE_PERCENT
: -TEXT_TRANSLATE_PERCENT;
const incomingStartRotate = goingNext ? -TEXT_ROTATE_DEG : TEXT_ROTATE_DEG;
const incomingStartX = goingNext
? -TEXT_TRANSLATE_PERCENT
: TEXT_TRANSLATE_PERCENT;
textTl.to(outgoingRef.current, {
xPercent: outgoingEndX,
rotateY: outgoingEndRotate,
duration: OUTGOING_DURATION * 1.5,
ease: resolvedEase,
}, 0);
textTl.to(outgoingRef.current, { opacity: 0, delay: -0.3, duration: 0 });
textTl.fromTo(incomingRef.current, { xPercent: incomingStartX, rotateY: incomingStartRotate, opacity: 0 }, {
xPercent: 0,
rotateY: 0,
opacity: 1,
duration: OUTGOING_DURATION * 1.5,
ease: resolvedEase,
}, 0.15);
}
tl.add(textTl, 0);
}, [flipAxis, isVertical, resolvedEase]);
const switchTo = useCallback((direction) => {
const goingNext = direction === "next";
const wasShowingNext = showingNextRef.current;
const currentVisibleIndex = wasShowingNext ? backIndex : frontIndex;
const rawIndex = currentVisibleIndex + (goingNext ? 1 : -1);
const newIndex = infinite
? ((rawIndex % ITEMS.length) + ITEMS.length) % ITEMS.length
: rawIndex;
if (!infinite && (newIndex < 0 || newIndex >= ITEMS.length))
return;
flipTo(direction, newIndex);
}, [backIndex, flipTo, frontIndex, infinite]);
useEffect(() => {
if (!autoplay || autoplayDelay <= 0 || prefersReducedMotion())
return;
const intervalId = window.setInterval(() => {
switchTo("next");
}, autoplayDelay);
return () => window.clearInterval(intervalId);
}, [autoplay, autoplayDelay, switchTo]);
const goToIndex = useCallback((targetIndex) => {
if (targetIndex === currentIndex)
return;
let direction;
if (infinite) {
const forwardDistance = ((targetIndex - currentIndex) % ITEMS.length + ITEMS.length) %
ITEMS.length;
direction = forwardDistance <= ITEMS.length - forwardDistance ? "next" : "prev";
}
else {
direction = targetIndex > currentIndex ? "next" : "prev";
}
flipTo(direction, targetIndex);
}, [currentIndex, flipTo, infinite]);
return (<>
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className={`dimensional-card relative ${cardClassName}`} style={{ perspective: "1000px", width: resolvedCardWidth, height: resolvedCardHeight }}>
<div ref={flipperRef} className="relative h-full w-full transform-3d">
{/* Front face */}
<div className="absolute inset-0 h-full w-full overflow-hidden backface-hidden prev-card-face" style={{ borderRadius: resolvedCardBorderRadius }}>
<Image src={ITEMS[frontIndex].image} className="w-full h-full object-cover" alt={ITEMS[frontIndex].text} width={300} height={250}/>
</div>
<div className={`absolute inset-0 h-full w-full overflow-hidden backface-hidden next-card-face ${isVertical ? "rotate-x-180" : "rotate-y-180"}`} style={{ borderRadius: resolvedCardBorderRadius }}>
<Image src={ITEMS[backIndex].image} className="w-full h-full object-cover" alt={ITEMS[backIndex].text} width={300} height={250}/>
</div>
</div>
</div>
<div className="absolute inset-0 z-10 flex items-center justify-center w-[60%] h-[25vw] max-md:w-[90%] max-md:h-[100vw] max-[1024px]:w-[90%] max-[1024px]:h-[60vw] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-md:text-[10vw]! max-[1024px]:text-[8vw]! " style={{ perspective: "1000px", fontSize: resolvedTextSize }}>
<div ref={prevTextRef} className="absolute w-fit text-center font-semibold prev-text" style={{ color: textColor }}>
{ITEMS[frontIndex].text}
</div>
<div ref={nextTextRef} className="absolute w-fit text-center font-semibold next-text opacity-0" style={{ color: textColor }}>
{ITEMS[backIndex].text}
</div>
</div>
<div className="flex gap-4 z-10 absolute bottom-8 max-md:bottom-40 max-[1024px]:bottom-32">
<button type="button" onClick={() => switchTo("prev")} aria-label="Previous" disabled={!infinite && currentIndex === 0} className="flex h-12 w-12 max-[1024px]:h-20 max-[1024px]:w-20 max-md:h-16 max-md:w-16 items-center justify-center rounded-full border border-white/20 bg-transparent text-white backdrop-blur-[10px] transition-colors duration-300 ease-in-out hover:bg-[#ff5f00] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent">
<ArrowLeft />
</button>
<button type="button" onClick={() => switchTo("next")} aria-label="Next" disabled={!infinite && currentIndex === ITEMS.length - 1} className="flex h-12 w-12 max-[1024px]:h-20 max-[1024px]:w-20 max-md:h-16 max-md:w-16 items-center justify-center rounded-full border border-white/20 bg-transparent text-white backdrop-blur-[10px] transition-colors duration-300 ease-in-out hover:bg-[#ff5f00] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent">
<ArrowRight />
</button>
</div>
<div className="flex gap-2 z-10 absolute bottom-30 max-[1024px]:bottom-64">
{ITEMS.map((item, idx) => (<button key={item.text} type="button" onClick={() => goToIndex(idx)} aria-label={`Go to ${item.text}`} aria-current={idx === currentIndex} className={`h-2 max-[1024px]:h-4 max-md:h-3 rounded-full transition-all duration-300 ease-in-out ${idx === currentIndex
? "w-6 max-[1024px]:w-8 bg-[#ff5f00]"
: "w-2 max-[1024px]:w-4 max-md:w-3 bg-white/30 hover:bg-white/50"}`}/>))}
</div>
</div>
</>);
};
export default DimensionalSwitchSlider;
A studio homepage can use Dimensional Switch Slider to present four core capabilities: strategy, design, motion, and frontend implementation. Each capability appears as a card with a visual, a short title, and a focused one-line explanation.
The outcome is presentation. The section feels designed and deliberate without requiring a full case-study grid above the fold.
A product company can use the same pattern to introduce feature pillars, templates, integrations, or launch modules. The dimensional motion gives each item weight while keeping the section compact.
Not for long lists, dense feature comparisons, pricing tables, testimonials that need quick scanning, legal information, or documentation.
Not for slides with too much text. A dimensional panel is not a brochure page wearing a mask.
Not for pages where the user needs to compare all items at once. Use a grid when comparison matters more than presentation.
Keep the slide count controlled, optimise images, reserve the card dimensions, and animate transform and opacity rather than layout properties. Avoid large shadows, heavy filters, and unnecessary 3D stacking across many elements.
Do not keep offscreen slides doing expensive work. Pause or simplify animation when the section is outside the viewport.
Use real buttons for previous and next controls. Add clear labels, keyboard support, visible focus states, and predictable slide order. Announce slide changes only when useful; do not spam assistive technology on every decorative motion frame.
On mobile, reduce perspective intensity, simplify card rotation, and keep controls easy to tap. For reduced motion, switch slides with a fade, instant state change, or minimal translate without 3D depth.
| Prop | Type | Default | Description |
|---|---|---|---|
infinite | boolean | true | Wraps from the last item back to the first (and vice versa) instead of clamping and disabling the prev/next buttons at the ends. |
direction | string | horizontal | Axis the card flips around - horizontal flips on Y with text sliding/tilting left-right, vertical flips on X with text sliding/tilting up-down. |
autoplay | boolean | true | Automatically advances to the next slide on a timer. |
autoplayDelay | number | 2600 | Time between automatic slide changes in milliseconds. |
textColor | string | #ffffff | Color applied to the slide text. |
textSize | number | string | 84 | Font size of the slide text. Numbers are treated as pixels; strings can use any CSS length. |
cardWidth | number | string | 680 | Width of the card. Numbers are treated as pixels; strings can use any CSS length. |
cardHeight | number | string | 460 | Height of the card. Numbers are treated as pixels; strings can use any CSS length. |
cardBorderRadius | number | string | 16 | Border radius applied to both card faces. Numbers are treated as pixels; strings can use any CSS length. |
Dimensional Switch Slider is a React carousel effect that transitions between visual cards using perspective, depth, tilt, scale, or 3D-style movement.
Use it for curated showcases: portfolio entries, feature pillars, visual services, product modules, campaign beats, and brand-led hero sections.
No. It works best with a small set of strong items. Long lists and dense comparisons are usually better handled with a grid or structured content section.
Yes, but keep it short. A strong title and one concise supporting line usually work better than long paragraphs.
Use semantic buttons, visible focus states, keyboard navigation, accessible slide labels, and real DOM text. Avoid relying only on visual motion to communicate the active item.
Reduced motion should remove or simplify the perspective transition. Use a quick fade, instant switch, or minimal movement while keeping all content available.
Yes, with tuning. Reduce tilt, depth, and travel distance so the slider feels stable on smaller screens.
Yes. A custom version should define slide structure, image ratios, navigation model, depth intensity, keyboard behavior, mobile fallback, accessibility states, reduced-motion handling, source handoff, and implementation notes.
Need a custom effect? Tell us what to create.