Minimap
Vertical scroll minimap with dynamic line scaling and a spring-following scroll indicator inspired by Devouring details.
Loading demo...
"use client";
import { cn } from "@/lib/utils";
import { motion, useScroll, useSpring, useTransform } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
const Minimap = () => {
const containerRef = useRef<HTMLDivElement>(null);
const LINE_COUNT = 26;
const GAP = 9;
const LINE_THICKNESS = 1;
const [lineScales, setLineScales] = useState<number[]>(
Array(LINE_COUNT).fill(1),
);
const { scrollYProgress } = useScroll();
const barY = useTransform(
scrollYProgress,
[0, 1],
[0, (containerRef.current?.offsetHeight ?? 0) - 1],
);
const barSpring = useSpring(barY, {
stiffness: 700,
damping: 60,
bounce: 0,
});
const barSpringForLines = useSpring(barSpring, {
visualDuration: 0.1,
bounce: 0,
});
const calculateScale = (distance: number) => {
const maxScale = 3.5;
const falloff = 3;
return 1 + (maxScale - 1) * Math.exp(-(distance * distance) / falloff);
};
const updateScales = useCallback((y: number) => {
const totalHeight = LINE_THICKNESS + GAP;
const exactIndex = y / totalHeight;
setLineScales((prev) =>
prev.map((_, i) => {
const distance = Math.abs(exactIndex - i);
return distance <= 2.5 ? calculateScale(distance) : 1;
}),
);
}, []);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
requestAnimationFrame(() => updateScales(y));
};
const handleMouseLeave = () => {
requestAnimationFrame(() => setLineScales(Array(LINE_COUNT).fill(1)));
};
useEffect(() => {
const unsub = barSpringForLines.on("change", (latest) => {
updateScales(latest);
});
return () => unsub();
}, [barSpringForLines, updateScales]);
const lineWidths = Array(LINE_COUNT)
.fill(null)
.map((_, i) => ((i + 1) % 5 === 1 ? 20 : 14));
return (
<div className="size-full flex flex-col items-center justify-center relative">
<div className="text-center">Scroll down</div>
<motion.div
ref={containerRef}
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
transition={{ duration: 0.5 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="fixed! left-0 top-0 bottom-0 h-fit my-auto"
>
<div className="flex flex-col gap-2.25 items-start pl-10">
{lineWidths.map((width, index) => (
<motion.div
key={index}
className={cn(
"h-px",
width === 20 ? "bg-primary/60" : "bg-primary",
)}
style={{
width,
}}
animate={{
scaleX: lineScales[index],
transformOrigin: "0% 50%",
}}
transition={{
type: "spring",
bounce: 0,
duration: 0.3,
}}
/>
))}
</div>
<motion.div
className="absolute left-10 top-0 h-px w-[calc(100vw-32px)] bg-orange-400"
style={{ y: barSpring }}
>
<div className="h-6 w-10 flex items-center justify-center absolute top-0 bottom-0 my-auto -left-8 rotate-180">
<svg width="8" height="10" viewBox="0 0 6 7" className="">
<path
className="fill-orange-400"
d="M0 3.5L5.25 0.5V6.5L0 3.5Z"
/>
</svg>
</div>
</motion.div>
</motion.div>
</div>
);
};
export default Minimap;