201 lines
6.5 KiB
TypeScript
201 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import {
|
|
motion,
|
|
useAnimationFrame,
|
|
useMotionTemplate,
|
|
useMotionValue,
|
|
useTransform,
|
|
animate,
|
|
} from "motion/react";
|
|
import { cn } from "@/lib/utils";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
export function AppleCardsCarousel({
|
|
items,
|
|
direction = "left",
|
|
speed = "fast",
|
|
pauseOnHover = true,
|
|
className,
|
|
}: {
|
|
items: {
|
|
title: string;
|
|
description: string;
|
|
icon: React.ReactNode;
|
|
src?: string;
|
|
}[];
|
|
direction?: "left" | "right";
|
|
speed?: "fast" | "normal" | "slow";
|
|
pauseOnHover?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [start, setStart] = useState(false);
|
|
useEffect(() => {
|
|
setStart(true);
|
|
}, []);
|
|
|
|
const baseVelocity = {
|
|
fast: 50,
|
|
normal: 35,
|
|
slow: 20,
|
|
};
|
|
|
|
const baseX = useMotionValue(0);
|
|
const directionFactor = React.useRef<number>(1);
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
useAnimationFrame((t, delta) => {
|
|
if (hovered) return;
|
|
|
|
let moveBy = directionFactor.current * baseVelocity[speed] * (delta / 1000);
|
|
|
|
if (direction === "left") {
|
|
directionFactor.current = -1;
|
|
} else {
|
|
directionFactor.current = 1;
|
|
}
|
|
|
|
moveBy += directionFactor.current * moveBy * 0.5;
|
|
|
|
baseX.set(baseX.get() + moveBy);
|
|
});
|
|
|
|
const scrollLeft = () => {
|
|
if (scrollRef.current) {
|
|
// Calculate responsive card width + gap
|
|
const cardWidth = window.innerWidth < 640 ? 280 : window.innerWidth < 768 ? 320 : 350;
|
|
const scrollAmount = cardWidth + 16; // card width + gap
|
|
const newX = baseX.get() + scrollAmount;
|
|
animate(baseX, newX, { duration: 0.5, ease: "easeOut" });
|
|
}
|
|
};
|
|
|
|
const scrollRight = () => {
|
|
if (scrollRef.current) {
|
|
// Calculate responsive card width + gap
|
|
const cardWidth = window.innerWidth < 640 ? 280 : window.innerWidth < 768 ? 320 : 350;
|
|
const scrollAmount = cardWidth + 16; // card width + gap
|
|
const newX = baseX.get() - scrollAmount;
|
|
animate(baseX, newX, { duration: 0.5, ease: "easeOut" });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"relative w-full overflow-hidden [mask-image:linear-gradient(to_right,transparent_0%,white_10%,white_90%,transparent_100%)]",
|
|
className
|
|
)}
|
|
onMouseEnter={() => pauseOnHover && setHovered(true)}
|
|
onMouseLeave={() => pauseOnHover && setHovered(false)}
|
|
>
|
|
<div className="flex justify-center">
|
|
<motion.div
|
|
ref={scrollRef}
|
|
style={{
|
|
x: baseX,
|
|
willChange: "transform",
|
|
}}
|
|
className="flex flex-nowrap gap-4 items-center"
|
|
>
|
|
{start &&
|
|
items.map((item, idx) => (
|
|
<div
|
|
key={`item-${idx}`}
|
|
className="relative shrink-0 w-[280px] sm:w-[320px] md:w-[350px]"
|
|
>
|
|
<div className="group relative h-[400px] sm:h-[420px] md:h-[450px] w-full overflow-hidden rounded-3xl p-6 sm:p-7 md:p-8">
|
|
{item.src && (
|
|
<div className="absolute inset-0">
|
|
<img
|
|
src={item.src}
|
|
alt={item.title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-br from-black/60 via-black/40 to-black/60" />
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 bg-[linear-gradient(to_bottom_right,var(--background)_0%,rgba(255,255,255,0.08)_50%,transparent_60%)] opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
<div className="relative z-10">
|
|
<div className="mb-4 flex items-center gap-4">
|
|
{item.icon}
|
|
<div>
|
|
<h3 className="text-heading-3 font-semibold text-overlay-foreground">
|
|
{item.title}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
<p className="text-body-sm text-overlay-foreground/90">
|
|
{item.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{start &&
|
|
items.map((item, idx) => (
|
|
<div
|
|
key={`item-duplicate-${idx}`}
|
|
className="relative shrink-0 w-[280px] sm:w-[320px] md:w-[350px]"
|
|
>
|
|
<div className="group relative h-[400px] sm:h-[420px] md:h-[450px] w-full overflow-hidden rounded-3xl p-6 sm:p-7 md:p-8">
|
|
{item.src && (
|
|
<div className="absolute inset-0">
|
|
<img
|
|
src={item.src}
|
|
alt={item.title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-br from-black/60 via-black/40 to-black/60" />
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 bg-[linear-gradient(to_bottom_right,var(--background)_0%,rgba(255,255,255,0.08)_50%,transparent_60%)] opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
<div className="relative z-10">
|
|
<div className="mb-4 flex items-center gap-4">
|
|
{item.icon}
|
|
<div>
|
|
<h3 className="text-heading-3 font-semibold text-overlay-foreground">
|
|
{item.title}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
<p className="text-body-sm text-overlay-foreground/90">
|
|
{item.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
size="icon-lg"
|
|
onClick={scrollRight}
|
|
aria-label="Scroll left"
|
|
>
|
|
<ChevronRight className="h-5 w-5" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon-lg"
|
|
onClick={scrollLeft}
|
|
aria-label="Scroll right"
|
|
>
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|