3D Cylinder Carousel with Drag to Spin and Zoom

A gallery arranged around a vertical cylinder, like a postcard rack. Twelve cards face outward and the whole ring turns slowly on its own. Drag sideways to spin it with momentum, use the arrow buttons or arrow keys to move one card at a time, and select a card to open a larger view with its title, details and a buy button. The caption under the ring always names the card facing you. On phones the cylinder is narrower so about three cards are in view, and vertical page scrolling still works over it.

Code

// Adapted from Cult UI (https://www.cult-ui.com/docs/components/three-d-carousel)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useAnimationFrame, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
import { ChevronLeft, ChevronRight, X } from "lucide-react";

/** One print per face of the cylinder. `sky` is top to bottom, `land` is the two hill colours. */
const prints = [
  { title: "First light", place: "Lofoten, Norway", sky: ["#1e1b4b", "#f97316"], land: ["#312e81", "#0f172a"], sun: "#fde68a" },
  { title: "Salt road", place: "Uyuni, Bolivia", sky: ["#bae6fd", "#fef3c7"], land: ["#e2e8f0", "#94a3b8"], sun: "#ffffff" },
  { title: "Green hour", place: "Kerry, Ireland", sky: ["#064e3b", "#a7f3d0"], land: ["#047857", "#022c22"], sun: "#ecfccb" },
  { title: "Red wall", place: "Wadi Rum, Jordan", sky: ["#7c2d12", "#fdba74"], land: ["#c2410c", "#431407"], sun: "#fff7ed" },
  { title: "Low tide", place: "Tofino, Canada", sky: ["#0c4a6e", "#7dd3fc"], land: ["#155e75", "#082f49"], sun: "#e0f2fe" },
  { title: "Blue dusk", place: "Chefchaouen, Morocco", sky: ["#1e3a8a", "#c4b5fd"], land: ["#3730a3", "#1e1b4b"], sun: "#f5d0fe" },
  { title: "High pass", place: "Ladakh, India", sky: ["#f1f5f9", "#93c5fd"], land: ["#64748b", "#1e293b"], sun: "#ffffff" },
  { title: "Ember", place: "Etna, Sicily", sky: ["#450a0a", "#f43f5e"], land: ["#881337", "#0c0a09"], sun: "#fecdd3" },
  { title: "Rice steps", place: "Sapa, Vietnam", sky: ["#365314", "#fde047"], land: ["#4d7c0f", "#1a2e05"], sun: "#fef9c3" },
  { title: "Quiet bay", place: "Hvar, Croatia", sky: ["#0e7490", "#fbcfe8"], land: ["#0f766e", "#134e4a"], sun: "#fff1f2" },
  { title: "Dune line", place: "Sossusvlei, Namibia", sky: ["#fcd34d", "#fb923c"], land: ["#b45309", "#451a03"], sun: "#fffbeb" },
  { title: "North glow", place: "Abisko, Sweden", sky: ["#020617", "#34d399"], land: ["#134e4a", "#020617"], sun: "#d1fae5" },
];

type Print = (typeof prints)[number];

function Scene({ p, id }: { p: Print; id: string }) {
  return (
    <svg viewBox="0 0 90 120" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 h-full w-full" aria-hidden="true">
      <defs>
        <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={p.sky[0]} />
          <stop offset="1" stopColor={p.sky[1]} />
        </linearGradient>
      </defs>
      <rect width="90" height="120" fill={`url(#${id})`} />
      <circle cx="60" cy="52" r="11" fill={p.sun} opacity="0.9" />
      <path d="M0 82 Q22 58 44 76 T90 66 V120 H0 Z" fill={p.land[0]} />
      <path d="M0 98 Q30 78 56 94 T90 88 V120 H0 Z" fill={p.land[1]} />
    </svg>
  );
}

export default function ThreeDCylinderCarouselWithDragToSpinAndZoom() {
  const reduce = useReducedMotion();
  const stage = useRef<HTMLDivElement>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const [small, setSmall] = useState(false);
  const [active, setActive] = useState<number | null>(null);
  const [front, setFront] = useState(0);
  const dragging = useRef(false);
  const moved = useRef(0);
  const settle = useRef<{ stop: () => void } | null>(null);

  const count = prints.length;
  const cylinderWidth = small ? 1100 : 2200;
  const faceWidth = cylinderWidth / count;
  const radius = cylinderWidth / (2 * Math.PI);
  const rotation = useMotionValue(0);
  const transform = useTransform(rotation, (v) => `rotate3d(0, 1, 0, ${v}deg)`);

  useEffect(() => {
    const q = window.matchMedia("(max-width: 640px)");
    const set = () => setSmall(q.matches);
    set();
    q.addEventListener("change", set);
    return () => q.removeEventListener("change", set);
  }, []);

  // Which print faces the viewer: used for the caption and for keyboard focus.
  useEffect(
    () =>
      rotation.on("change", (v) => {
        const i = ((Math.round(-v / (360 / count)) % count) + count) % count;
        setFront(i);
      }),
    [rotation, count]
  );

  // A slow idle turn whenever nobody is dragging or looking at a print.
  useAnimationFrame((_, delta) => {
    if (reduce || dragging.current || active !== null || settle.current) return;
    rotation.set(rotation.get() - Math.min(delta, 50) * 0.006);
  });

  const spinTo = (target: number) => {
    settle.current?.stop();
    const controls = animate(rotation, target, reduce ? { duration: 0 } : { type: "spring", stiffness: 100, damping: 30, mass: 0.6 });
    settle.current = controls;
    controls.then(() => {
      if (settle.current === controls) settle.current = null;
    });
  };
  const step = (dir: number) => {
    const each = 360 / count;
    spinTo((Math.round(rotation.get() / each) - dir) * each);
  };

  useEffect(() => {
    if (active === null) return;
    closeButton.current?.focus();
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setActive(null);
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [active]);

  const shown = active === null ? null : prints[active];

  return (
    <section className="relative overflow-hidden bg-neutral-950 px-4 py-14 text-white sm:py-16">
      <div className="mx-auto max-w-3xl text-center">
        <p className="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-400">Far Light Editions</p>
        <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">Twelve prints, one slow turn.</h2>
        <p className="mt-3 text-base text-neutral-400">Drag to spin the rack. Pick a print to see it up close.</p>
      </div>

      <div ref={stage} className="relative mx-auto mt-6 h-[400px] max-w-6xl sm:h-[500px]">
        <div aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 z-10 w-16 bg-gradient-to-r from-neutral-950 to-transparent sm:w-40" />
        <div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 z-10 w-16 bg-gradient-to-l from-neutral-950 to-transparent sm:w-40" />

        <motion.div
          className="flex h-full cursor-grab items-center justify-center active:cursor-grabbing"
          style={{ perspective: 1000, touchAction: "pan-y" }}
          onPanStart={() => {
            settle.current?.stop();
            settle.current = null;
            dragging.current = true;
            moved.current = 0;
          }}
          onPan={(_, info) => {
            moved.current += Math.abs(info.delta.x);
            rotation.set(rotation.get() + info.delta.x * 0.12);
          }}
          onPanEnd={(_, info) => {
            dragging.current = false;
            spinTo(rotation.get() + info.velocity.x * 0.05);
          }}
        >
          <motion.ul className="relative h-[230px] sm:h-[300px]" style={{ width: cylinderWidth, transform, transformStyle: "preserve-3d" }}>
            {prints.map((p, i) => (
              <li
                key={p.title}
                className="absolute left-1/2 top-0 h-full p-2"
                style={{ width: faceWidth, marginLeft: -faceWidth / 2, transform: `rotateY(${i * (360 / count)}deg) translateZ(${radius}px)`, backfaceVisibility: "hidden" }}
              >
                <button
                  type="button"
                  tabIndex={i === front && active === null ? 0 : -1}
                  aria-label={`Open ${p.title}, ${p.place}`}
                  onClick={() => moved.current < 6 && setActive(i)}
                  onKeyDown={(e) => {
                    if (e.key === "ArrowRight") step(1);
                    if (e.key === "ArrowLeft") step(-1);
                  }}
                  className="relative block h-full w-full overflow-hidden rounded-xl outline-none ring-1 ring-white/10 focus-visible:ring-2 focus-visible:ring-white"
                >
                  <Scene p={p} id={`ring-sky-${i}`} />
                </button>
              </li>
            ))}
          </motion.ul>
        </motion.div>

        <AnimatePresence>
          {shown && (
            <motion.div
              key="zoom"
              role="dialog"
              aria-modal="true"
              aria-labelledby="ring-print-title"
              className="absolute inset-0 z-20 flex items-center justify-center rounded-3xl bg-black/70 p-4 backdrop-blur-sm"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: reduce ? 0 : 0.25 }}
              onClick={() => setActive(null)}
            >
              <motion.div
                onClick={(e) => e.stopPropagation()}
                initial={reduce ? false : { scale: 0.6, y: 30, filter: "blur(8px)" }}
                animate={{ scale: 1, y: 0, filter: "blur(0px)" }}
                exit={reduce ? { opacity: 0 } : { scale: 0.7, opacity: 0, filter: "blur(6px)" }}
                transition={{ type: "spring", stiffness: 260, damping: 26 }}
                className="flex w-full max-w-md items-stretch gap-4 rounded-2xl bg-neutral-900 p-3 shadow-2xl ring-1 ring-white/10"
              >
                <div className="relative h-56 w-40 shrink-0 overflow-hidden rounded-xl sm:h-64 sm:w-48">
                  <Scene p={shown} id="ring-sky-zoom" />
                </div>
                <div className="flex min-w-0 flex-1 flex-col py-1 pr-1">
                  <button
                    ref={closeButton}
                    type="button"
                    aria-label="Close print"
                    onClick={() => setActive(null)}
                    className="ml-auto grid h-10 w-10 place-items-center rounded-full bg-white/10 outline-none hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white"
                  >
                    <X className="h-4 w-4" aria-hidden="true" />
                  </button>
                  <h3 id="ring-print-title" className="mt-auto text-xl font-semibold">
                    {shown.title}
                  </h3>
                  <p className="text-sm text-neutral-400">{shown.place}</p>
                  <p className="mt-2 text-sm text-neutral-300">Giclee on cotton rag, 40 by 50 cm. $65</p>
                  <a href="#" className="mt-3 inline-flex h-10 items-center justify-center rounded-full bg-white px-4 text-sm font-semibold text-neutral-950 outline-none hover:bg-neutral-200 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900">
                    Add to basket
                  </a>
                </div>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <div className="mx-auto flex max-w-sm items-center justify-between gap-4">
        <button type="button" aria-label="Previous print" onClick={() => step(-1)} className="grid h-11 w-11 place-items-center rounded-full border border-white/15 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white">
          <ChevronLeft className="h-5 w-5" aria-hidden="true" />
        </button>
        <p aria-live="polite" className="min-w-0 text-center text-sm">
          <span className="font-medium">{prints[front].title}</span>
          <span className="text-neutral-400"> ยท {prints[front].place}</span>
        </p>
        <button type="button" aria-label="Next print" onClick={() => step(1)} className="grid h-11 w-11 place-items-center rounded-full border border-white/15 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white">
          <ChevronRight className="h-5 w-5" aria-hidden="true" />
        </button>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/3d-cylinder-carousel-with-drag-to-spin-and-zoom.json

When to use it

How to customize it

Accessibility

Questions

How are the cards placed around the cylinder?

Each face is rotated around the vertical axis by its share of 360 degrees, then pushed outward with `translateZ` by the radius. The radius is the circumference divided by two pi, so the faces meet edge to edge.

Why can I not see the cards at the back?

Each face has `backfaceVisibility: hidden`, which keeps the view clean. Remove that style to see the far side of the ring through the gaps.

How does a click differ from a drag?

The pan handler adds up how far the pointer moved. A card only opens when that total is under six pixels, so letting go after a spin never opens a card by accident.

Can I change the number of cards?

Yes. Add or remove entries in `prints`. Face width and angles are worked out from the array length. With fewer than eight cards, reduce `cylinderWidth` so the faces do not become too wide.

Is the zoom a real modal?

In this demo it covers the carousel frame only, so it displays correctly inside a preview. For a full page overlay change the dialog wrapper from `absolute inset-0` to `fixed inset-0`.

Adapted from 3D Carousel in Cult UI, used under its open-source licence. Licences and credits

Related: Coverflow Gallery with 3D Perspective and Autoplay, Rotating 3D Cube Carousel with Drag and Autoplay, Draggable Card Stack Carousel with Spring Physics.

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