loading
Just a moment.

Subtle 3D Carousel

November 2024

UI Design

Design intuitive user interfaces and experiences.

Frontend Development

Build interactive, visually compelling web pages.

Motion Design

Create engaging animations and transitions.

Design Engineer

Focusing on details, design systems, and code.

Product Management

Manage product lifecycle, from conception to launch.

import { PanInfo, motion, useMotionValue, useTransform } from 'framer-motion';
import { useState } from 'react';

interface IconProps {
  className?: string;
}

const CodeIcon = (props: IconProps) => (
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={props.className}>
    <path strokeLinecap="round" strokeLinejoin="round" d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z" />
    <path strokeLinecap="round" strokeLinejoin="round" d="M2.015 8L1 7h4.994C5.532 7 7 5.477 7 4.995V3c0-1.103.897-2 2-2h1.015zM4 4h3v3l2-2zm9 0h3v3l2-2z" />
  </svg>
);

const ITEMS = [
  {
    title: 'UI Design',
    description: 'Design intuitive user interfaces and experiences.',
    icon: <CodeIcon className="h-4 w-4 text-primary-light-11 dark:text-primary-dark-11" />,
    id: 1,
  },
  {
    title: 'Frontend Development',
    description: 'Build interactive, visually compelling web pages.',
    icon: <CodeIcon className="h-4 w-4 text-primary-light-11 dark:text-primary-dark-11" />,
    id: 2,
  },
  {
    title: 'Motion Design',
    description: 'Create engaging animations and transitions.',
    icon: <CodeIcon className="h-4 w-4 text-primary-light-11 dark:text-primary-dark-11" />,
    id: 3,
  },
  {
    title: 'Design Engineer',
    description: 'Focusing on details, design systems, and code.',
    icon: <CodeIcon className="h-4 w-4 text-primary-light-11 dark:text-primary-dark-11" />,
    id: 4,
  },
  {
    title: 'Product Management',
    description: 'Manage product lifecycle, from conception to launch.',
    icon: <CodeIcon className="h-4 w-4 text-primary-light-11 dark:text-primary-dark-11" />,
    id: 5,
  },
];

const ITEM_WIDTH = 200;
const DRAG_BUFFER = 50;
const VELOCITY_THRESHOLD = 500;
const GAP = 16;
const CONTAINER_WIDTH = ITEM_WIDTH + GAP;

const SPRING_OPTIONS = {
  type: 'spring',
  stiffness: 300,
  damping: 30,
};

const Subtle3DCarousel = () => {
  const x = useMotionValue(0);
  const [currentIndex, setCurrentIndex] = useState(0);

  const handleDragEnd = (_: unknown, info: PanInfo) => {
    const offset = info.offset.x;
    const velocity = info.velocity.x;

    if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) {
      setCurrentIndex((prev) => Math.min(prev + 1, ITEMS.length - 1));
    } else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) {
      setCurrentIndex((prev) => Math.max(prev - 1, 0));
    }
  };

  const leftConstraint = -((ITEM_WIDTH + GAP) * (ITEMS.length - 1));

  return (
    <div className="relative overflow-hidden rounded-[var(--outer-r)] border p-[var(--p-distance)] [--outer-r:24px] [--p-distance:16px] border-white/10">
      <motion.div
        className="flex"
        drag="x"
        dragConstraints={{
          left: leftConstraint,
          right: 0,
        }}
        style={{
          width: ITEM_WIDTH,
          gap: `${GAP}px`,
          perspective: 1000,
          perspectiveOrigin: currentIndex * ITEM_WIDTH + ITEM_WIDTH / 2,
          x,
        }}
        onDragEnd={handleDragEnd}
        animate={{ x: -(currentIndex * (ITEM_WIDTH + GAP)) }}
        transition={SPRING_OPTIONS}
      >
        {ITEMS.map((item, index) => {
          const range = [
            (-100 * (index + 1) * CONTAINER_WIDTH) / 100,
            (-100 * index * CONTAINER_WIDTH) / 100,
            (-100 * (index - 1) * CONTAINER_WIDTH) / 100,
          ];
          const nextIndex = Math.min(index + 1, ITEMS.length - 1);
          const prevIndex = Math.max(index - 1, 0);
          const outputRange = [nextIndex ? 90 : 90, 0, prevIndex ? -90 : -90];
          const rotateY = useTransform(x, range, outputRange, {
            clamp: false,
          });

          return (
            <motion.div
              key={index}
              className="relative flex shrink-0 flex-col items-start justify-between rounded-[calc(var(--outer-r)-var(--p-distance))] border border-white/10 bg-[#101010]"
              style={{
                width: ITEM_WIDTH,
                height: '100%',
                rotateY: rotateY,
              }}
              transition={SPRING_OPTIONS}
            >
              <div className="mb-4 px-5 pt-5">
                <span className="flex h-7 w-7 items-center justify-center rounded-full bg-[#151515]">
                  {item.icon}
                </span>
              </div>
              <div className="px-5 pb-5">
                <div className="mb-1 text-sm font-medium text-white">
                  {item.title}
                </div>
                <p className="text-sm text-white/50">
                  {item.description}
                </p>
              </div>
            </motion.div>
          );
        })}
      </motion.div>
      <div className="flex w-full justify-center">
        <div className="mt-4 flex w-[150px] justify-between px-8">
          {ITEMS.map((_, index) => (
            <motion.div
              key={index}
              className={`h-2 w-2 cursor-pointer rounded-full transition-colors duration-150 ${currentIndex === index
                ? 'bg-white/60'
                : 'bg-white/10'
                }`}
              animate={{ scale: currentIndex === index ? 1.2 : 1 }}
              onClick={() => setCurrentIndex(index)}
              transition={{
                duration: 0.15,
              }}
            />
          ))}
        </div>
      </div>
    </div>
  );
};

export default Subtle3DCarousel;