gidl
Components

Collins Carousel

Carousel Interaction from Collins, heavily inspired by Dimi's implementation.

  • Precision Stylus
    Precision Stylus
  • Zenith Kettle
    Zenith Kettle
  • Prism Table Lamp
    Prism Table Lamp
  • Studio Mic Arm
    Studio Mic Arm
  • Vertex Monitor Stand
    Vertex Monitor Stand
"use client";

import { Carousel, useTicker, useTickerItem } from "@/lib/motion-carousel";
import { motion, useMotionValueEvent, useTransform } from "motion/react";
import { useRef } from "react";

function CarouselItem({
item,
count,
}: {
item: { src: string; title: string };
count: number;
}) {
const { offset, props, start, end, itemIndex } = useTickerItem();
const { renderedOffset, totalItemLength } = useTicker();
const itemWidth = end - start;
const draggingBeyond = useRef({ start: false, end: false });

const isFirst = itemIndex === 0;
const isLast = itemIndex === count - 1;

const getKeyframes = (
type: "rotateY" | "x" | "textOpacity"
): { in: number[]; out: string[] } => {
const w = itemWidth;
if (type === "rotateY") {
  if (isFirst)
    return {
      in: [-w, -w / 2, 0, w],
      out: ["40deg", "25deg", "0deg", "-15deg"],
    };
  if (isLast)
    return {
      in: [-w, 0, w / 2, w],
      out: ["15deg", "0deg", "-25deg", "-40deg"],
    };
  return {
    in: [-w, -w / 2, 0, w / 2, w],
    out: ["40deg", "25deg", "0deg", "-25deg", "-40deg"],
  };
}
if (type === "x") {
  if (isFirst)
    return { in: [w, 0, -w / 2, -w], out: ["30%", "0%", "-45.5%", "-30%"] };
  if (isLast)
    return { in: [w, w / 2, 0, -w], out: ["30%", "45.5%", "0%", "-30%"] };
  return {
    in: [w, w / 2, 0, -w / 2, -w],
    out: ["30%", "45.5%", "0%", "-45.5%", "-30%"],
  };
}
// textOpacity
if (isFirst)
  return { in: [-w, -w / 4, 0, w], out: ["0%", "0%", "100%", "0%"] };
if (isLast)
  return { in: [-w, 0, w / 4, w], out: ["0%", "100%", "0%", "0%"] };
return {
  in: [-w, -w / 4, 0, w / 4, w],
  out: ["0%", "0%", "100%", "0%", "0%"],
};
};

const rotateYKF = getKeyframes("rotateY");
const xKF = getKeyframes("x");
const textOpacityKF = getKeyframes("textOpacity");

const rotateY = useTransform(offset, rotateYKF.in, rotateYKF.out);
const x = useTransform(offset, xKF.in, xKF.out);
const textOpacity = useTransform(offset, textOpacityKF.in, textOpacityKF.out);

const z = useTransform(offset, (value) => {
const { start: beyondStart, end: beyondEnd } = draggingBeyond.current;
if (beyondStart) return Math.abs(start / itemWidth) * -(itemWidth * 0.55);
if (beyondEnd)
  return (
    Math.abs((end - totalItemLength) / itemWidth) * -(itemWidth * 0.55)
  );
return Math.abs(value / itemWidth) * -(itemWidth * 0.55);
});

const edgeX = useTransform(renderedOffset, (r) => {
const { start: beyondStart, end: beyondEnd } = draggingBeyond.current;
if (beyondStart) return Math.abs((r / 50) * (count + itemIndex * 2));
if (beyondEnd) {
  const diff = Math.abs(r) - Math.abs(totalItemLength - itemWidth);
  return -(diff / 50) * (count * 3 - itemIndex * 2 - 2);
}
return 0;
});

const opacity = useTransform(offset, (value) => {
const { start: beyondStart, end: beyondEnd } = draggingBeyond.current;
if (beyondStart) return itemIndex <= 1 ? 1 : 0;
if (beyondEnd) return itemIndex >= count - 2 ? 1 : 0;
if (value > end + itemWidth / 2 || value < -end - itemWidth / 2) return 0;
return 1;
});

const zIndex = useTransform(offset, (value) =>
Math.max(0, Math.round(1000 - Math.abs(value)))
);

useMotionValueEvent(renderedOffset, "change", (r) => {
draggingBeyond.current = {
  start: r > 0,
  end: Math.abs(totalItemLength - itemWidth) < Math.abs(r),
};
});

return (
<motion.li {...props} style={{ ...props.style, zIndex }} className="w-full h-full">
  <motion.div style={{ x: edgeX }} className="size-full">
    <motion.div className="size-full">
      <motion.div
        style={{
          transformPerspective: itemWidth * 2,
          transformStyle: "preserve-3d",
          x,
          z,
          rotateY,
          opacity,
        }}
        className="flex flex-col justify-center will-change-transform"
      >
        <div className="aspect-[3/4] h-full w-full overflow-hidden rounded-xl md:rounded-2xl">
          <img
            draggable={false}
            src={item.src}
            alt={item.title}
            className="h-full w-full object-cover"
          />
        </div>
        <motion.div
          style={{ opacity: textOpacity }}
          className="mt-6 text-center font-sans md:mt-8"
        >
          {item.title}
        </motion.div>
      </motion.div>
    </motion.div>
  </motion.div>
</motion.li>
);
}

export default function CollinsCarousel() {
return (
<div className="flex h-full w-full items-center justify-center py-10">
  <Carousel
    className="w-[85vw] max-w-[320px] cursor-grab active:cursor-grabbing"
    items={ITEMS.map((item, index) => (
      <CarouselItem key={index} item={item} count={ITEMS.length} />
    ))}
    loop={false}
    layout="stack"
    overflow
    gap={0}
    itemSize="manual"
    safeMargin={200}
    dragElastic={0.5}
    dragSensitivity={1}
    momentumMultiplier={0.1}
    springConfig={{ stiffness: 100, damping: 20, mass: 1.2 }}
  />
</div>
);
}

const ITEMS = [
{
src: "https://cdn.midjourney.com/bcf287e4-b28c-4b9a-94cd-18afc3bd408c/0_2.png",
title: "Precision Stylus",
},
{
src: "https://cdn.midjourney.com/b4d102e1-2a33-4c38-9cfb-3841e698c9cc/0_2.png",
title: "Zenith Kettle",
},
{
src: "https://cdn.midjourney.com/a2e325a8-d6c8-4d0c-8864-5883dc0f2100/0_0.png",
title: "Prism Table Lamp",
},
{
src: "https://cdn.midjourney.com/1a172b9e-bef8-46a6-974e-3f3b77fff1a8/0_0.png",
title: "Studio Mic Arm",
},
{
src: "https://cdn.midjourney.com/8ab643f4-b0de-40d7-9355-32272d43a347/0_3.png",
title: "Vertex Monitor Stand",
},
];

About

Built to emulate dynamic fluid motion natively using Framer Motion and Radix UI primitives. Ensures seamless integration with existing React architectures.

Installation

npx gidl add collins-carousel

Install the following dependencies:

npm install motion/react

Copy and paste the code into your project.

Update the import paths to match your project setup.

Usage

import { CollinsCarousel } from "@/components/ui/collins-carousel";

export default function App() {
  return <CollinsCarousel />;
}

On this page