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
Zenith Kettle
Prism Table Lamp
Studio Mic Arm
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",
},
];