'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
HTMLMotionProps,
MapInputRange,
motion,
MotionValue,
useMotionTemplate,
useReducedMotion,
useScroll,
useSpring,
useTransform,
} from 'motion/react';
interface ScrollAnimationContextValue {
scrollProgress: MotionValue<number>;
}
const ScrollAnimationContext = React.createContext<
ScrollAnimationContextValue | undefined
>(undefined);
export function useScrollAnimationContext() {
const context = React.useContext(ScrollAnimationContext);
if (!context) {
throw new Error(
'useScrollAnimationContext must be used within a ScrollAnimationContextProvider',
);
}
return context;
}
interface ScrollAnimationProps extends React.ComponentPropsWithRef<'div'> {
spacerClass?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
offset?: any[];
}
export function ScrollAnimation({
spacerClass,
offset,
className,
children,
...props
}: ScrollAnimationProps) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: scrollRef,
offset: offset,
});
const smoothProgress = useSpring(scrollYProgress, {
damping: 30,
stiffness: 400,
restDelta: 0.001,
});
const reducedMotion = useReducedMotion();
const scrollProgress = reducedMotion ? scrollYProgress : smoothProgress;
return (
<ScrollAnimationContext.Provider value={{ scrollProgress }}>
<div ref={scrollRef} className={cn('relative', className)} {...props}>
{children}
<div className={cn('w-full h-96', spacerClass)} />
</div>
</ScrollAnimationContext.Provider>
);
}
export function ScrollInsetX({
insetRange = [48, 0],
inputRange = [0, 1],
className,
style,
...props
}: HTMLMotionProps<'div'> & { insetRange?: number[]; inputRange?: number[] }) {
const { scrollProgress } = useScrollAnimationContext();
const xInset = useTransform(scrollProgress, inputRange, insetRange);
const clipPath = useMotionTemplate`inset(0px ${xInset}px)`;
return (
<motion.div
className={className}
style={{ clipPath, willChange: 'clip-path', ...style }}
{...props}
/>
);
}
export function ScrollInsetY({
insetRange = [48, 0],
inputRange = [0, 1],
className,
style,
...props
}: HTMLMotionProps<'div'> & { insetRange?: number[]; inputRange?: number[] }) {
const { scrollProgress } = useScrollAnimationContext();
const yInset = useTransform(scrollProgress, inputRange, insetRange);
const clipPath = useMotionTemplate`inset(${yInset}px 0px)`;
return (
<motion.div
className={className}
style={{ clipPath, willChange: 'clip-path', ...style }}
{...props}
/>
);
}
export function ScrollInset({
inputRange = [0, 1],
insetRangeY = [45, 0],
insetXRange = [45, 0],
roundednessRange = [16, 16],
className,
style,
...props
}: HTMLMotionProps<'div'> & {
inputRange?: MapInputRange;
insetRangeY?: unknown[];
insetXRange?: unknown[];
roundednessRange?: unknown[];
}) {
const { scrollProgress } = useScrollAnimationContext();
const insetY = useTransform(scrollProgress, inputRange, insetRangeY);
const insetX = useTransform(scrollProgress, inputRange, insetXRange);
const roundedness = useTransform(
scrollProgress,
inputRange,
roundednessRange,
);
const clipPath = useMotionTemplate`inset(${insetY}% ${insetX}% ${insetY}% ${insetX}% round ${roundedness}px)`;
return (
<motion.div
className={className}
style={{ clipPath, willChange: 'clip-path', ...style }}
{...props}
/>
);
}
export function ScrollTranslateY({
yRange = [0, 384],
inputRange = [0, 1],
style,
className,
...props
}: HTMLMotionProps<'div'> & { yRange?: unknown[]; inputRange?: number[] }) {
const { scrollProgress } = useScrollAnimationContext();
const y = useTransform(scrollProgress, inputRange, yRange);
return (
<motion.div
style={{ y, willChange: 'transform', ...style }}
className={cn('relative origin-top', className)}
{...props}
/>
);
}
export function ScrollTranslateX({
xRange = [0, 100],
inputRange = [0, 1],
style,
className,
...props
}: HTMLMotionProps<'div'> & { xRange?: unknown[]; inputRange?: number[] }) {
const { scrollProgress } = useScrollAnimationContext();
const x = useTransform(scrollProgress, inputRange, xRange);
return (
<motion.div
style={{ x, willChange: 'transform', ...style }}
className={cn('relative origin-top', className)}
{...props}
/>
);
}
export function ScrollOpacity({
opacityRange = [0, 1],
inputRange = [0, 1],
style,
...props
}: HTMLMotionProps<'div'> & {
opacityRange?: unknown[];
inputRange?: number[];
}) {
const { scrollProgress } = useScrollAnimationContext();
const opacity = useTransform(scrollProgress, inputRange, opacityRange);
return (
<motion.div
style={{ opacity, willChange: 'opacity', ...style }}
{...props}
/>
);
}
export function ScrollScale({
scaleRange = [1.2, 1],
inputRange = [0, 1],
className,
style,
...props
}: HTMLMotionProps<'div'> & { scaleRange?: unknown[]; inputRange?: number[] }) {
const { scrollProgress } = useScrollAnimationContext();
const scale = useTransform(scrollProgress, inputRange, scaleRange);
return (
<motion.div
className={className}
style={{ scale, willChange: 'transform', ...style }}
{...props}
/>
);
}
export function ScrollRadius({
radiusRange = [9999, 16],
inputRange = [0, 1],
className,
style,
...props
}: HTMLMotionProps<'div'> & {
radiusRange?: unknown[];
inputRange?: number[];
}) {
const { scrollProgress } = useScrollAnimationContext();
const borderRadius = useTransform(scrollProgress, inputRange, radiusRange);
return (
<motion.div
layout
className={className}
style={{ borderRadius, willChange: 'border-radius', ...style }}
{...props}
/>
);
}