Collins Carousel

December 2025

Reproducing the carousel from the Collins website case study page using the new Motion+ Carousel component.

Demo

Products

Where form meets function

  • 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

Code

"use client";

import { Carousel, useTicker, useTickerItem } from "motion-plus/react";
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 containerX = useTransform(
    renderedOffset,
    (r) => -r - offset.get() / itemWidth - start
  );

  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 }}>
      <motion.div
        style={{
          x: edgeX,
        }}
        className="size-full"
      >
        <motion.div style={{ x: containerX }} 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-[min(62vw,320px)] 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 flex-1 flex-col items-center justify-center space-y-6 overflow-hidden md:space-y-12">
      <div className="space-y-2 md:space-y-4">
        <h1 className="text-center font-serif text-3xl md:text-4xl">
          Products
        </h1>
        <p className="text-center font-sans text-muted-3">
          Where form meets function
        </p>
      </div>
      <Carousel
        className="flex w-[min(62vw,320px)] cursor-grab active:cursor-grabbing"
        items={ITEMS.map((item, index) => (
          <CarouselItem key={index} item={item} count={ITEMS.length} />
        ))}
        loop={false}
        overflow
        gap={0}
        itemSize="manual"
        safeMargin={200}
        dragElastic={0.1}
      />
    </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",
  },
];