80 lines
2.3 KiB
TypeScript
80 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Infinite marquee whose speed + skew react to scroll velocity.
|
|
* - Base drift via Motion `animationFrame` + a wrapped motion value.
|
|
* - Scroll velocity (useVelocity over scrollY) adds directional boost + skew,
|
|
* so the tape "leans" as you fling the page — a signature kinetic touch.
|
|
* - Two copies of the content guarantee a seamless loop.
|
|
* - prefers-reduced-motion: static row, no transform churn.
|
|
*/
|
|
import { useRef } from "react";
|
|
import {
|
|
motion,
|
|
useScroll,
|
|
useVelocity,
|
|
useSpring,
|
|
useTransform,
|
|
useMotionValue,
|
|
useAnimationFrame,
|
|
useReducedMotion,
|
|
wrap,
|
|
} from "motion/react";
|
|
|
|
export default function VelocityMarquee({
|
|
items,
|
|
baseVelocity = 60,
|
|
}: {
|
|
items: string[];
|
|
baseVelocity?: number;
|
|
}) {
|
|
const reduce = useReducedMotion();
|
|
const baseX = useMotionValue(0);
|
|
const { scrollY } = useScroll();
|
|
const scrollVelocity = useVelocity(scrollY);
|
|
const smoothVelocity = useSpring(scrollVelocity, { damping: 50, stiffness: 400 });
|
|
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 4], { clamp: false });
|
|
const skew = useTransform(smoothVelocity, [-2000, 0, 2000], [-6, 0, 6], { clamp: true });
|
|
|
|
// 50% because we render the content twice; wrap keeps it seamless.
|
|
const x = useTransform(baseX, (v) => `${wrap(-50, 0, v)}%`);
|
|
const directionFactor = useRef(1);
|
|
|
|
useAnimationFrame((_, delta) => {
|
|
if (reduce) return;
|
|
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
|
|
const vf = velocityFactor.get();
|
|
if (vf < 0) directionFactor.current = -1;
|
|
else if (vf > 0) directionFactor.current = 1;
|
|
moveBy += directionFactor.current * moveBy * vf;
|
|
baseX.set(baseX.get() + moveBy / 14);
|
|
});
|
|
|
|
const Row = (
|
|
<>
|
|
{items.map((it, i) => (
|
|
<span className="marq__item" key={i}>
|
|
{it}
|
|
<span className="marq__star" aria-hidden="true">✱</span>
|
|
</span>
|
|
))}
|
|
</>
|
|
);
|
|
|
|
if (reduce) {
|
|
return (
|
|
<div className="marq" aria-hidden="true">
|
|
<div className="marq__track marq__track--static">{Row}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<motion.div className="marq" style={{ skewX: skew }} aria-hidden="true">
|
|
<motion.div className="marq__track" style={{ x }}>
|
|
{Row}
|
|
{Row}
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|