UI Elements

Circular Gallery

A draggable circular gallery of images that spins, snaps to the nearest item, and expands a selected image to fill the frame.

circular-gallery.tsx
"use client";

import { X } from "lucide-react";
import {
  animate,
  AnimatePresence,
  motion,
  useMotionValue,
  useMotionValueEvent,
} from "motion/react";
import { useRef, useState } from "react";

interface Element {
  id: number;
  img: string;
}

const items: Element[] = [
  {
    id: 1,
    img: "https://plus.unsplash.com/premium_photo-1664474619075-644dd191935f?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8aW1hZ2V8ZW58MHx8MHx8fDA%3D",
  },
  {
    id: 2,
    img: "https://media.istockphoto.com/id/1168977609/photo/paparazzi-photographer-in-petropolis-rio-de-janeiro.jpg?s=612x612&w=0&k=20&c=R0XWB0-J0s-kbWfW6RgYGvNGMxnMSProeb8_KH-D2AA=",
  },
  {
    id: 3,
    img: "https://cdn.create.vista.com/api/media/small/137693132/stock-photo-woman-photographer-holding-a-camera-in-the-wild-for-take-a-photo",
  },
  {
    id: 4,
    img: "https://media.istockphoto.com/id/1049506008/photo/senior-african-american-woman-hiking-with-camera.jpg?b=1&s=612x612&w=0&k=20&c=xqaVJQFpF2KmL04zaVK_FF0CFAlaZdF39nRKOTBnBC4=",
  },
  {
    id: 5,
    img: "https://media.istockphoto.com/id/1336160936/photo/freelance-female-photographer-holding-a-camera.jpg?s=612x612&w=0&k=20&c=zlUnQT31KdlJCfDycdJauJVeiKO11IhtjESAjZhaTvQ=",
  },
  {
    id: 6,
    img: "https://st3.depositphotos.com/3433891/33505/i/450/depositphotos_335052976-stock-photo-young-african-american-photographer-woman.jpg",
  },
  {
    id: 7,
    img: "https://st4.depositphotos.com/13194036/22991/i/450/depositphotos_229919434-stock-photo-beautiful-smiling-girl-using-photo.jpg",
  },
  {
    id: 8,
    img: "https://st4.depositphotos.com/13194036/i/600/depositphotos_229919408-stock-photo-beautiful-young-woman-using-photo.jpg",
  },
  {
    id: 9,
    img: "https://img.freepik.com/premium-photo/young-photographer-standing-front-reflective-umbrella_53876-82660.jpg",
  },
  {
    id: 10,
    img: "https://img.freepik.com/premium-photo/side-view-woman-working-as-photographer_23-2150506106.jpg",
  },
  {
    id: 11,
    img: "https://media.istockphoto.com/id/1069722364/photo/young-african-photographer-at-work-at-studio.jpg?s=612x612&w=0&k=20&c=sgY8pDSSNSj4YIZYBVb-N2vW4TlV9ZNjXTdMFhJ1new=",
  },
  {
    id: 12,
    img: "https://media.istockphoto.com/id/121349708/photo/young-asian-girl-taking-a-photo.jpg?s=612x612&w=0&k=20&c=GRstiRHSDMhnSYlGOmlSe2_tv0Hrk_ogJmMydfE-nbI=",
  },
];

const Wheel = () => {
  const [activeElement, setActiveElement] = useState<Element | null>(null);

  const motionX = useMotionValue(0);
  const rotate = useMotionValue(0);
  const accumulatedRotation = useRef(0);

  const radius = 190;
  const snapAngle = 360 / items.length;

  useMotionValueEvent(motionX, "change", (latest) => {
    rotate.set(accumulatedRotation.current + latest / 5);
  });

  const handleDrag = (_: any, info: any) => {
    motionX.set(info.offset.x);
  };

  const handleDragEnd = () => {
    const currentRotation = rotate.get();
    const normalizedRotation = ((currentRotation % 360) + 360) % 360;
    const nearestMultiple =
      Math.round(normalizedRotation / snapAngle) * snapAngle;
    const finalRotation =
      Math.floor(currentRotation / 360) * 360 + nearestMultiple;

    accumulatedRotation.current = finalRotation;

    animate(rotate, finalRotation, {
      type: "spring",
      stiffness: 300,
      damping: 30,
    });
  };

  return (
    <div className="relative size-full flex items-center justify-center">
      <div className="relative size-full flex items-center justify-center overflow-hidden rounded-xl bg-background p-4">
        <motion.div
          drag="x"
          dragConstraints={{ left: 0, right: 0 }}
          dragElastic={0}
          onDrag={handleDrag}
          onDragEnd={handleDragEnd}
          style={{ rotate }}
          className="relative size-full cursor-grab rounded-lg bg-transparent flex items-center justify-center active:cursor-grabbing"
        >
          {items.map((ele, index) => {
            const angle = (index / items.length) * 2 * Math.PI;
            const x = radius * Math.cos(angle);
            const y = radius * Math.sin(angle);
            const rotation = (index * 360) / items.length;

            return (
              <motion.div
                key={ele.id}
                className="absolute size-20 p-1 cursor-pointer shadow-lg shadow-black/10 rounded-lg"
                style={{ x, y, rotate: rotation }}
                onClick={() => setActiveElement(ele)}
              >
                <motion.img
                  src={ele.img}
                  alt="Image"
                  layoutId={`spinner-${ele.id}`}
                  className="size-full object-cover rounded-lg border-2 border-white"
                />
              </motion.div>
            );
          })}
        </motion.div>
        <h1 className="text-black text-4xl font-bold absolute bottom-0 left-0 right-0 top-0 m-auto size-fit z-10">
          Spin
        </h1>
      </div>
      <ActiveItem
        activeElement={activeElement}
        setActiveElement={setActiveElement}
      />
    </div>
  );
};

interface ActiveItemProps {
  activeElement: Element | null;
  setActiveElement: (element: Element | null) => void;
}

const ActiveItem = ({ activeElement, setActiveElement }: ActiveItemProps) => {
  return (
    <AnimatePresence>
      {activeElement && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.3 }}
          className="absolute inset-0 flex items-center justify-center z-20 bg-background"
        >
          <motion.div
            layoutId={`spinner-${activeElement.id}`}
            className="w-[400px] h-[400px] rounded-3xl overflow-hidden relative bg-background p-2 shadow-lg shadow-black/10"
          >
            <button
              className="absolute right-5 top-5 size-10 text-white bg-primary/50 backdrop-blur-md rounded-full flex items-center justify-center"
              onClick={() => setActiveElement(null)}
            >
              <X className="w-6 h-6" />
            </button>
            <motion.img
              src={activeElement.img}
              alt="Image"
              className="w-full h-full object-cover rounded-2xl border-2 border-white"
            />
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
};

export default Wheel;