Minimap

Vertical scroll minimap with dynamic line scaling and a spring-following scroll indicator inspired by Devouring details.

Loading demo...

default.tsx
"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;