Rotating 3D Cube Carousel with Drag and Autoplay

A travel guide section with a 3D box on one side and the current slide's details on the other. Each slide is a poster on one face of the box. The box turns a quarter turn to bring the next face forward, with faces darkening as they turn away. It advances by itself, pauses while the pointer or focus is inside the section, and can be dragged left or right, stepped with two buttons or driven with the arrow keys. Six slides are included, but any number works because the hidden face is refilled as the box turns. On phones the box sits above the text and scales to the screen width.

Code

// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/carousel/box-carousel)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
import type { MotionValue } from "framer-motion";

// Any number of slides works: the cube has four sides and the hidden one is refilled as it turns.
const guides = [
  { city: "Lisbon", country: "Portugal", line: "Tiled hills, late dinners and the 28 tram.", nights: "3 nights from $410", bg: "#ffb703", ink: "#3d1d00", shape: "#fb5607" },
  { city: "Kyoto", country: "Japan", line: "Moss gardens before the crowds wake up.", nights: "5 nights from $980", bg: "#e63946", ink: "#fff5f0", shape: "#7a0f1c" },
  { city: "Oaxaca", country: "Mexico", line: "Seven moles, one market, a lot of mezcal.", nights: "4 nights from $520", bg: "#2a9d8f", ink: "#f1fff9", shape: "#e9c46a" },
  { city: "Tbilisi", country: "Georgia", line: "Sulphur baths, balconies and long toasts.", nights: "4 nights from $390", bg: "#3a0ca3", ink: "#f3edff", shape: "#f72585" },
  { city: "Hobart", country: "Tasmania", line: "Harbour mornings under a mountain.", nights: "5 nights from $760", bg: "#caf0f8", ink: "#03263b", shape: "#0077b6" },
  { city: "Bergen", country: "Norway", line: "Seven hills, wooden wharves, soft rain.", nights: "3 nights from $640", bg: "#1d3557", ink: "#f1faee", shape: "#a8dadc" },
];

const AUTOPLAY_MS = 2800;
const DRAG_DEGREES_PER_PIXEL = 0.4;
const mod = (n: number, m: number) => ((n % m) + m) % m;

function Face({ side, step, rotation }: { side: number; step: number; rotation: MotionValue<number> }) {
  // Which slide this side should carry: the front shows `step`, the right `step + 1`, the back `step + 2`, the left `step - 1`.
  const offset = mod(side - step, 4);
  const guide = guides[mod(step + (offset === 3 ? -1 : offset), guides.length)];
  // Sides turning away from the viewer darken, which is most of what sells the depth.
  const shade = useTransform(rotation, (r) => Math.min(0.55, (1 - Math.cos(((side * 90 + r) * Math.PI) / 180)) * 0.5));
  return (
    <div
      className="absolute inset-0 flex select-none flex-col justify-between overflow-hidden p-6 sm:p-8"
      style={{ background: guide.bg, color: guide.ink, transform: `rotateY(${side * 90}deg) translateZ(var(--half))`, backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
    >
      <span aria-hidden="true" className="absolute -right-16 -top-16 h-56 w-56 rounded-full" style={{ background: guide.shape }} />
      <span aria-hidden="true" className="absolute -bottom-24 -left-10 h-48 w-[140%] -rotate-6" style={{ background: guide.shape, opacity: 0.35 }} />
      <div className="relative flex items-center justify-between font-mono text-xs font-bold uppercase tracking-widest">
        <span>City guide</span>
        <span>{guide.country}</span>
      </div>
      <div className="relative">
        <p className="text-5xl font-black leading-none tracking-tight sm:text-6xl">{guide.city}</p>
        <p className="mt-3 max-w-[16rem] text-sm font-medium leading-snug">{guide.line}</p>
      </div>
      <motion.span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-black" style={{ opacity: shade }} />
    </div>
  );
}

export default function Rotating3dCubeCarouselWithDragAndAutoplay() {
  const reduce = useReducedMotion();
  const [step, setStep] = useState(0);
  const [paused, setPaused] = useState(false);
  const stepRef = useRef(0);
  const rotation = useMotionValue(0);
  const transform = useTransform(rotation, (r) => `translateZ(calc(var(--half) * -1)) rotateY(${r}deg)`);
  const drag = useRef<{ x: number; from: number } | null>(null);

  const turnTo = (next: number, spring = false) => {
    stepRef.current = next;
    setStep(next);
    animate(rotation, -90 * next, reduce ? { duration: 0 } : spring ? { type: "spring", stiffness: 190, damping: 26 } : { duration: 1.1, ease: [0.85, 0, 0.15, 1] });
  };
  const go = (dir: number) => turnTo(stepRef.current + dir);

  useEffect(() => {
    if (paused || reduce) return;
    const id = window.setInterval(() => go(1), AUTOPLAY_MS);
    return () => window.clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [paused, reduce]);

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    rotation.stop();
    drag.current = { x: e.clientX, from: rotation.get() };
    e.currentTarget.setPointerCapture(e.pointerId);
    setPaused(true);
  };
  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!drag.current) return;
    const rest = -90 * stepRef.current;
    const wanted = drag.current.from + (e.clientX - drag.current.x) * DRAG_DEGREES_PER_PIXEL;
    rotation.set(Math.max(rest - 88, Math.min(rest + 88, wanted)));
  };
  const onPointerUp = () => {
    if (!drag.current) return;
    drag.current = null;
    turnTo(Math.round(-rotation.get() / 90), true);
  };
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    e.preventDefault();
    go(e.key === "ArrowRight" ? 1 : -1);
  };

  const current = mod(step, guides.length);
  const guide = guides[current];
  const control = "inline-flex h-12 w-12 items-center justify-center rounded-full border border-neutral-300 bg-white text-neutral-900 transition hover:border-neutral-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-100";

  return (
    <section className="overflow-hidden bg-neutral-100 px-6 py-16 text-neutral-900 sm:py-20" onPointerEnter={() => setPaused(true)} onPointerLeave={() => !drag.current && setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)}>
      <div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2 lg:gap-16">
        <div
          role="group"
          aria-roledescription="carousel"
          aria-label="City guides"
          tabIndex={0}
          onKeyDown={onKeyDown}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
          className="mx-auto cursor-grab touch-pan-y rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-8 focus-visible:ring-offset-neutral-100 active:cursor-grabbing"
          style={{ ["--w" as string]: "min(74vw, 380px)", ["--half" as string]: "calc(var(--w) / 2)", width: "var(--w)", height: "calc(var(--w) * 1.22)", perspective: "1100px" }}
        >
          <motion.div className="relative h-full w-full" style={{ transformStyle: "preserve-3d", transform }}>
            {[0, 1, 2, 3].map((side) => (
              <Face key={side} side={side} step={step} rotation={rotation} />
            ))}
          </motion.div>
        </div>

        <div>
          <p className="font-mono text-xs uppercase tracking-[0.25em] text-neutral-600">Slow Atlas / Weekend guides</p>
          <h2 className="mt-4 text-4xl font-black tracking-tight sm:text-5xl">Six cities worth a long weekend.</h2>
          <div className="mt-8 min-h-[9.5rem] border-l-4 pl-5" style={{ borderColor: guide.bg }} aria-live="polite">
            <AnimatePresence mode="wait" initial={false}>
              <motion.div key={current} initial={reduce ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : -10 }} transition={{ duration: reduce ? 0 : 0.25 }}>
                <p className="font-mono text-xs uppercase tracking-widest text-neutral-600">
                  {String(current + 1).padStart(2, "0")} / {String(guides.length).padStart(2, "0")}
                </p>
                <p className="mt-2 text-2xl font-bold">
                  {guide.city}, {guide.country}
                </p>
                <p className="mt-1 text-base leading-relaxed text-neutral-700">{guide.line}</p>
                <p className="mt-2 text-sm font-semibold text-neutral-900">{guide.nights}</p>
              </motion.div>
            </AnimatePresence>
          </div>
          <div className="mt-8 flex flex-wrap items-center gap-3">
            <button type="button" aria-label="Previous city" onClick={() => go(-1)} className={control}>
              <svg aria-hidden="true" viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M12 4l-6 6 6 6" />
              </svg>
            </button>
            <button type="button" aria-label="Next city" onClick={() => go(1)} className={control}>
              <svg aria-hidden="true" viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M8 4l6 6-6 6" />
              </svg>
            </button>
            <a href="#" className="inline-flex h-12 items-center rounded-full bg-neutral-900 px-7 text-sm font-semibold text-white transition hover:bg-neutral-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-100">
              Read the guide
            </a>
          </div>
          <p className="mt-5 text-sm text-neutral-600">Drag the cube, use the arrows, or let it turn on its own.</p>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/rotating-3d-cube-carousel-with-drag-and-autoplay.json

When to use it

How to customize it

Accessibility

Questions

How can a four-sided cube show six or more slides?

Only the front face and its two neighbours can ever be seen. The fourth face is at the back, so its content can be swapped freely. Each face works out which slide it should carry from its position relative to the current step, and the back face always holds the slide two steps ahead.

How is the cube built?

Four absolutely positioned faces share one parent with `transform-style: preserve-3d`. Each face is rotated by a multiple of 90 degrees and pushed outward by half the width. The parent is pulled back by the same half width so the front face sits at its natural size, then rotated.

How does dragging work?

Pointer events with pointer capture convert horizontal movement into degrees. The rotation is clamped to just under a quarter turn either way, and on release the cube springs to the nearest face.

Why do the faces get darker as they turn?

Each face has a black overlay whose opacity follows the angle between the face and the viewer. It imitates light falling on a real box and makes the rotation read clearly even with flat colours.

The faces flicker or show through each other. Why?

Check that every face has `backface-visibility: hidden` and that nothing between the perspective element and the faces sets `overflow: hidden`, `filter` or `opacity` below 1, since those flatten 3D rendering.

Adapted from Box Carousel in Fancy Components, used under its open-source licence. Licences and credits

Related: Coverflow Gallery with 3D Perspective and Autoplay, Draggable Card Stack Carousel with Spring Physics, Flip Cards with Details on the Back.

More galleries and carousels or all free UI components. Start for free to have a full site built around it.

More in Galleries

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap