Skip to content

3D Card Carousel

Cards mounted on a rotating 3D ring — the active one faces forward, others recede.

Freeplayfulglass
Category
Patterns
Added
2026-08-22
Deps
1
Updated
2026-09-01
Preview
Design
Build
Ship
Iterate

Props

Requires framer-motion
"use client";

import { useState } from "react";
import { motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";

export function CardCarousel3D({ cards = ["Design", "Build", "Ship", "Iterate"] }: { cards?: string[] }) {
  const [active, setActive] = useState(0);
  const radius = 130;

  return (
    <div className="flex flex-col items-center gap-6">
      <div className="relative h-48 w-64" style={{ perspective: 900 }}>
        <div className="relative h-full w-full [transform-style:preserve-3d]">
          {cards.map((card, i) => {
            const offset = i - active;
            const angle = offset * (360 / cards.length);
            return (
              <motion.div
                key={card}
                role={offset === 0 ? undefined : "button"}
                tabIndex={offset === 0 ? undefined : 0}
                aria-label={offset === 0 ? undefined : `Show card: ${card}`}
                onClick={offset === 0 ? undefined : () => setActive(i)}
                onKeyDown={
                  offset === 0
                    ? undefined
                    : (e) => {
                        if (e.key === "Enter" || e.key === " ") {
                          e.preventDefault();
                          setActive(i);
                        }
                      }
                }
                className={`absolute left-1/2 top-1/2 flex h-28 w-40 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-2xl border border-neutral-200 bg-white font-bold shadow-lg [backface-visibility:hidden] ${offset === 0 ? "" : "cursor-pointer"}`}
                animate={{
                  rotateY: angle,
                  z: offset === 0 ? radius : radius * 0.4,
                  opacity: Math.abs(offset) > 1 ? 0 : 1,
                }}
                transition={{ type: "spring", stiffness: 220, damping: 26 }}
                style={{ transformStyle: "preserve-3d" }}
              >
                {card}
              </motion.div>
            );
          })}
        </div>
      </div>
      <div className="flex gap-3">
        <button
          aria-label="Previous card"
          onClick={() => setActive((a) => (a - 1 + cards.length) % cards.length)}
          className="flex h-8 w-8 items-center justify-center rounded-full border border-neutral-200 text-neutral-500 hover:text-neutral-900"
        >
          <ChevronLeft size={16} />
        </button>
        <button
          aria-label="Next card"
          onClick={() => setActive((a) => (a + 1) % cards.length)}
          className="flex h-8 w-8 items-center justify-center rounded-full border border-neutral-200 text-neutral-500 hover:text-neutral-900"
        >
          <ChevronRight size={16} />
        </button>
      </div>
    </div>
  );
}
Notes

About this pattern

A 3D carousel usually means pulling in a library or fighting matrix3d transforms by hand; this one gets the drum effect from three CSS properties: perspective on the outer container, transform-style: preserve-3d on the inner track, and a per-card rotateY plus z-translate computed from each card's offset from the active index, animated with a spring so switching cards rotates the whole ring rather than snapping. The math is plain arithmetic inline inside the .map() callback, offset times 360 divided by card count, not a per-card hook call, so mapping directly in the parent does not run into the rules-of-hooks trouble a per-card useTransform would. Prev/next buttons step the active index, and clicking (or focusing + Enter/Space on) any of the receded side cards jumps straight to it. Reach for it over a flat scroll-snap carousel when the 3D depth is worth the extra complexity; for a simple gallery, scroll-snap is lighter and needs no JavaScript at all.

Curator’s note

The per-card rotateY/z/opacity here is plain arithmetic from the loop index, not a per-card hook call — so unlike Sticky Scroll Stack (which needed a real per-card subcomponent because it called useTransform per card), mapping directly over the cards array in the parent is correct and doesn't violate the rules of hooks.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Featured
NewPro

A carousel whose side cards recede in scale, blur and rotation from one offset value.

carousel3ddepth
Featured
NewPro

A rotating word wheel built on a real cylinder — derived radius, accumulated rotation, no backwards spin on the wrap.

type3dcarousel
Featured
New

A rail you can throw — it projects the momentum and settles on the nearest slide.

dragcarouselinertia