2025-08-20 04:12:49 -06:00

212 lines
6.2 KiB
TypeScript

'use client';
import * as React from 'react';
import {
AnimatePresence,
motion,
useMotionValue,
useSpring,
useTransform,
useInView,
type SpringOptions,
type UseInViewOptions,
} from 'motion/react';
import { cn } from '@workspace/ui/lib/utils';
import { Star } from '@/registry/icons/star';
const formatter = new Intl.NumberFormat('en-US');
function generateRange(
max: number,
step: number,
sideItemsCount: number,
): number[] {
const result: number[] = [];
const end = max + sideItemsCount * step;
for (let value = end; value >= 0; value -= step) {
result.push(value);
}
return result;
}
type StarsScrollingWheelProps = {
stars: number;
step?: number;
itemHeight?: number;
sideItemsCount?: number;
transition?: SpringOptions;
inView?: boolean;
inViewOnce?: boolean;
inViewMargin?: UseInViewOptions['margin'];
delay?: number;
} & React.ComponentProps<'div'>;
function StarsScrollingWheel({
ref,
stars,
step = 100,
itemHeight = 48,
sideItemsCount = 2,
transition = { stiffness: 90, damping: 30 },
inView = false,
inViewOnce = true,
inViewMargin = '0px',
delay = 0,
className,
style,
...props
}: StarsScrollingWheelProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
const inViewResult = useInView(containerRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
const displayedItemsCount = 1 + sideItemsCount * 2;
const range = React.useMemo(
() => generateRange(stars, step, sideItemsCount),
[stars, step, sideItemsCount],
);
const initialY = -(itemHeight * sideItemsCount);
const finalY = itemHeight * (range.length - displayedItemsCount);
const yMotion = useMotionValue(initialY);
const ySpring = useSpring(yMotion, transition);
React.useEffect(() => {
if (!isInView) return;
const timer = setTimeout(() => {
yMotion.set(finalY);
}, delay);
return () => clearTimeout(timer);
}, [isInView, finalY, yMotion, delay]);
const currentIndex = useTransform(
ySpring,
(y) => y / itemHeight + sideItemsCount,
);
const currentValue = useTransform(currentIndex, (idx) => idx * step);
const completedTransform = useTransform(
currentValue,
(val) => val >= stars * 0.99,
);
const [isCompleted, setCompleted] = React.useState<boolean>(
completedTransform.get(),
);
React.useEffect(() => {
const unsubscribe = completedTransform.on('change', (latest) => {
if (latest) setCompleted(true);
});
return unsubscribe;
}, [completedTransform]);
return (
<div
ref={containerRef}
className={cn(
'relative overflow-hidden w-[200px] bg-background',
className,
)}
style={{ height: itemHeight * displayedItemsCount, ...style }}
{...props}
>
<div
className="absolute z-2 top-0 inset-x-0 bg-gradient-to-t from-transparent to-background"
style={{ height: itemHeight }}
/>
<div
className="absolute z-1 top-0 inset-x-0 bg-background/60"
style={{ height: itemHeight * sideItemsCount }}
/>
<div
className="absolute z-1 bottom-0 inset-x-0 bg-gradient-to-b from-transparent to-background"
style={{ height: itemHeight }}
/>
<div
className="absolute z-1 bottom-0 inset-x-0 bg-background/60"
style={{ height: itemHeight * sideItemsCount }}
/>
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex items-center justify-center">
<div
className="w-full bg-muted rounded-xl flex items-center justify-start px-6"
style={{ height: itemHeight }}
>
<div className="relative inline-flex size-[28px] shrink-0">
<Star
animation="fill"
animate={isCompleted}
className="text-yellow-500"
/>
<AnimatePresence>
{isCompleted && (
<>
<motion.div
className="absolute inset-0 rounded-full"
style={{
background:
'radial-gradient(circle, rgba(255,215,0,0.4) 0%, rgba(255,215,0,0) 70%)',
}}
initial={{ scale: 1.2, opacity: 0 }}
animate={{ scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] }}
transition={{ duration: 1.2, ease: 'easeInOut' }}
/>
<motion.div
className="absolute inset-0 rounded-full"
style={{ boxShadow: '0 0 10px 2px rgba(255,215,0,0.6)' }}
initial={{ scale: 1, opacity: 0 }}
animate={{ scale: [1, 1.5], opacity: [0.8, 0] }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute w-1 h-1 rounded-full bg-yellow-500"
initial={{ x: '50%', y: '50%', scale: 0, opacity: 0 }}
animate={{
x: `calc(50% + ${Math.cos((i * Math.PI) / 3) * 30}px)`,
y: `calc(50% + ${Math.sin((i * Math.PI) / 3) * 30}px)`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
}}
transition={{
duration: 0.8,
delay: i * 0.05,
ease: 'easeOut',
}}
/>
))}
</>
)}
</AnimatePresence>
</div>
</div>
</div>
<motion.div
className="absolute left-17 bottom-0 text-start flex items-center justify-center flex-col"
style={{ y: ySpring }}
>
{range.map((value) => (
<div
key={value}
className="text-2xl font-bold flex items-center justify-start w-full"
style={{ height: itemHeight }}
>
{formatter.format(value)}
</div>
))}
</motion.div>
</div>
);
}
export { StarsScrollingWheel, type StarsScrollingWheelProps };