niceui

Cylinder Carousel

A 3D rotating cylinder of cards built with CSS transforms, draggable and keyboard navigable.

ReactTailwind CSSTypeScriptComponentsMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/components-cylinder-carousel.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/ui/motion/CylinderCarousel.tsx
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/cn';

export type CylinderCard = { id: string; title: string; subtitle: string; gradient: string };

export type CylinderCarouselProps = {
  cards: CylinderCard[];
  cardWidth?: number;
  cardHeight?: number;
  /** Degrees rotated per pixel dragged. */
  sensitivity?: number;
};

export function CylinderCarousel({ cards, cardWidth = 200, cardHeight = 260, sensitivity = 0.35 }: CylinderCarouselProps) {
  const n = cards.length;
  const step = 360 / n;
  const radius = Math.round(cardWidth / 2 / Math.tan(Math.PI / n)) + 24;
  const [angle, setAngle] = useState(0);
  const [dragging, setDragging] = useState(false);
  const drag = useRef({ x: 0, angle: 0, moved: false });
  const root = useRef<HTMLDivElement>(null);

  const index = (((Math.round(-angle / step) % n) + n) % n);
  const go = (dir: number) => setAngle((a) => Math.round(a / step) * step - dir * step);

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'ArrowRight') go(1);
      if (e.key === 'ArrowLeft') go(-1);
    };
    el.addEventListener('keydown', onKey);
    return () => el.removeEventListener('keydown', onKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [step]);

  const onDown = (e: PointerEvent) => {
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    drag.current = { x: e.clientX, angle, moved: false };
    setDragging(true);
  };
  const onMove = (e: PointerEvent) => {
    if (!dragging) return;
    const dx = e.clientX - drag.current.x;
    if (Math.abs(dx) > 3) drag.current.moved = true;
    setAngle(drag.current.angle + dx * sensitivity);
  };
  const onUp = () => {
    if (!dragging) return;
    setDragging(false);
    setAngle((a) => Math.round(a / step) * step);
  };

  return (
    <div ref={root} tabIndex={0} className="focus-ring flex flex-col items-center gap-6 rounded-2xl outline-none" aria-roledescription="carousel">
      <div
        className={cn('relative w-full touch-pan-y select-none', dragging ? 'cursor-grabbing' : 'cursor-grab')}
        style={{ height: cardHeight + 40, perspective: 1100 }}
        onPointerDown={onDown}
        onPointerMove={onMove}
        onPointerUp={onUp}
        onPointerCancel={onUp}
      >
        <div
          className="absolute left-1/2 top-5"
          style={{
            width: cardWidth,
            height: cardHeight,
            marginLeft: -cardWidth / 2,
            transformStyle: 'preserve-3d',
            transform: `translateZ(${-radius}px) rotateY(${angle}deg)`,
            transition: dragging ? 'none' : 'transform 700ms cubic-bezier(0.34,1.2,0.64,1)',
          }}
        >
          {cards.map((c, i) => {
            const rel = ((((i * step + angle) % 360) + 540) % 360) - 180; // -180..180, 0 = front
            const facing = Math.cos((rel * Math.PI) / 180);
            return (
              <div
                key={c.id}
                onClick={() => !drag.current.moved && setAngle(Math.round(angle / step) * step - Math.round(rel / step) * step)}
                className="absolute inset-0 overflow-hidden rounded-2xl shadow-pop"
                style={{
                  transform: `rotateY(${i * step}deg) translateZ(${radius}px)`,
                  background: c.gradient,
                  opacity: 0.25 + 0.75 * Math.max(0, facing),
                  filter: `brightness(${0.5 + 0.5 * Math.max(0, facing)})`,
                  transition: dragging ? 'none' : 'opacity 500ms, filter 500ms',
                  backfaceVisibility: 'hidden',
                }}
              >
                <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-4">
                  <p className="text-sm font-medium text-white">{c.title}</p>
                  <p className="text-xs text-white/70">{c.subtitle}</p>
                </div>
              </div>
            );
          })}
        </div>
      </div>
      <div className="flex items-center gap-4">
        <button onClick={() => go(-1)} aria-label="Previous" className="focus-ring flex h-9 w-9 items-center justify-center rounded-full bg-ink-850 text-ink-200 shadow-hairline transition hover:bg-ink-800 hover:text-ink-50 active:scale-90">
          <ChevronLeft className="h-4 w-4" />
        </button>
        <div className="flex gap-1.5">
          {cards.map((c, i) => (
            <span key={c.id} className={cn('h-1.5 rounded-full transition-all duration-500 ease-spring', i === index ? 'w-5 bg-ink-50' : 'w-1.5 bg-ink-600')} />
          ))}
        </div>
        <button onClick={() => go(1)} aria-label="Next" className="focus-ring flex h-9 w-9 items-center justify-center rounded-full bg-ink-850 text-ink-200 shadow-hairline transition hover:bg-ink-800 hover:text-ink-50 active:scale-90">
          <ChevronRight className="h-4 w-4" />
        </button>
      </div>
    </div>
  );
}

const g = (a: string, b: string, base: string) => `radial-gradient(circle at 25% 20%, ${a}, transparent 55%), radial-gradient(circle at 80% 85%, ${b}, transparent 60%), ${base}`;

const CARDS: CylinderCard[] = [
  { id: 'kyoto', title: 'Kyoto', subtitle: 'Temples at dawn', gradient: g('#f43f5e', '#f59e0b', '#2a0f14') },
  { id: 'reykjavik', title: 'Reykjavík', subtitle: 'Northern lights', gradient: g('#2dd4bf', '#38bdf8', '#071f1a') },
  { id: 'lisbon', title: 'Lisbon', subtitle: 'Golden hour trams', gradient: g('#fbbf24', '#fb7185', '#2b1a06') },
  { id: 'patagonia', title: 'Patagonia', subtitle: 'Glacier trek', gradient: g('#bae6fd', '#0ea5e9', '#0b1a33') },
  { id: 'marrakech', title: 'Marrakech', subtitle: 'Spice markets', gradient: g('#f97316', '#f43f5e', '#2a1206') },
  { id: 'banff', title: 'Banff', subtitle: 'Lake Louise', gradient: g('#10b981', '#7dd3fc', '#062019') },
  { id: 'santorini', title: 'Santorini', subtitle: 'Caldera sunset', gradient: g('#38bdf8', '#fbbf24', '#0b1a33') },
  { id: 'hanoi', title: 'Hanoi', subtitle: 'Old quarter nights', gradient: g('#fb7185', '#14b8a6', '#1e0b14') },
];

export function CylinderCarouselDemo() {
  return (
    <div className="mx-auto w-full max-w-3xl">
      <CylinderCarousel cards={CARDS} />
    </div>
  );
}