https://www.youtube.com/watch?v=PczQ0qSwe1E

https://www.youtube.com/watch?v=hjbxaYTMhy0&list=PLA4qBVt61k3Phpwt7uqaptIg9NYZ5aNu_

https://motion.dev/docs/react-use-scroll

Staggered Animation.webp

// CascadePage.tsx
import * as React from "react";
import { motion, useScroll, useTransform, useSpring } from "framer-motion";
import { animate } from "motion/react";

type Photo = { src: string; alt?: string };
type Props = { images: Photo[]; className?: string };

const animationVariants = {
  initial: {
    opacity: 0,
    y: 100,
  },
  animate: (index: number) => ({
    opacity: 1,
    y: 0,
    transition: {
      delay: 0.15 * index,
    },
  }),
};

export default function CascadePage({ images, className = "" }: Props) {
  const root = React.useRef<HTMLDivElement>(null);

  // Track scroll progress for this section (0 → 1)
  const { scrollYProgress } = useScroll({
    target: root,
    offset: ["start start", "end end"],
  });

  // --- Stagger mapping ---
  const N = images.length;

  // Lead-in: keep the grid perfectly still at the top (0–6% of scroll)
  const LEAD = 0.01;

  // Each card gets the same scroll "band" after the lead-in
  const BAND = (1 - LEAD) / Math.max(1, N);

  // Helpers
  const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
  const smooth = (t: number) => t * t * (3 - 2 * t); // smoothstep

  // For each image, derive y/scale/opacity from scrollYProgress with a per-item delay.
  const transforms = React.useMemo(
    () =>
      Array.from({ length: N }, (_, k) => {
        // For card k, start after LEAD + k*BAND
        const y = useTransform(scrollYProgress, (p) => {
          if (p <= LEAD) return 0; // frozen at the very top
          const start = LEAD + k * BAND;
          const local = clamp01((p - start) / BAND); // 0→1 during its band
          // Move up gently (tune 0→-32px as you like)
          return -32 * smooth(local);
        });

        const scaleRaw = useTransform(scrollYProgress, (p) => {
          if (p <= LEAD) return 0.96;
          const start = LEAD + k * BAND;
          const local = clamp01((p - start) / BAND);
          // Subtle scale-in (0.96 → 1.0)
          return 0.95 + 0.04 * smooth(local);
        });

        const opacityRaw = useTransform(scrollYProgress, (p) => {
          if (p <= LEAD) return 1; // already visible at the top
          const start = LEAD + k * BAND;
          const local = clamp01((p - start) / BAND);
          // Optional: slight fade-in to emphasize staggering
          return 0.8 + 0.2 * smooth(local);
        });

        // Springs make the motion feel nicer on quick scrolls
        const scale = useSpring(scaleRaw, {
          stiffness: 200,
          damping: 28,
          mass: 0.6,
        });
        const opacity = useSpring(opacityRaw, {
          stiffness: 120,
          damping: 24,
          mass: 0.6,
        });

        return { y, scale, opacity };
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [N]
  );

  return (
    <main
      ref={root}
      className={`min-h-screen w-full bg-slate-950 text-slate-100 ${className}`}
    >
      {/* Top progress bar */}
      <motion.div
        className="fixed left-0 top-0 h-1 w-full origin-left bg-white/60 z-50"
        style={{ scaleX: scrollYProgress }}
      />

      <section className="cf-grid mx-auto max-w-6xl px-0.5 pb-24 pt-16">
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
          {images.map((photo, i) => (
            <motion.figure
              key={i}
              style={transforms[i]} // <-- y / scale / opacity from scroll
              className="rounded-sm cf-card group relative overflow-hidden bg-slate-900/40 ring-1 ring-white/10 will-change-transform hover:scale-103
              hover:shadow-2xl hover:shadow-slate-500"
              transition={{ type: "spring", stiffness: 200, damping: 20 }}
              variants={animationVariants}
              initial={"initial"}
              animate={"animate"}
              custom={i}
            >
              <div className="relative aspect-[4/3] w-full">
                <img
                  src={photo.src}
                  alt={photo.alt ?? `Photo ${i + 1}`}
                  loading="lazy"
                  className="absolute inset-0 h-full w-full object-cover transition-transform duration-300"
                  draggable={false}
                />
              </div>
            </motion.figure>
          ))}
        </div>
      </section>
    </main>
  );
}

import React from "react";
import { motion } from "framer-motion";

// Hover‑Play Video Gallery
// Tech: React + TailwindCSS (no external libs)
// Behavior: Grid of video thumbnails (16:9). On hover/focus, video auto‑plays
// (muted, inline) and pauses/resets when hover leaves or card exits viewport.
// Includes demo items using free, publicly hosted sample videos.

const animationVariants = {
  initial: {
    opacity: 0,
    y: 100,
  },
  animate: (index: number) => ({
    opacity: 1,
    y: 0,
    transition: {
      delay: 0.3 * index,
    },
  }),
};

export type VideoItem = {
  id: string;
  title: string;
  src: string; // mp4/webm URL
  poster: string; // fallback poster image
};

const demoItems: VideoItem[] = [
  {
    id: "bunny",
    title: "Big Buck Bunny",
    src: "<https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4>",
    poster: unsplash(1),
  },
  {
    id: "elephants",
    title: "Elephants Dream",
    src: "<https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4>",
    poster: unsplash(2),
  },
  {
    id: "escapes",
    title: "For Bigger Escapes",
    src: "<https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4>",
    poster: unsplash(3),
  },
  {
    id: "joyrides",
    title: "For Bigger Joyrides",
    src: "<https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4>",
    poster: unsplash(4),
  },
  {
    id: "meltdowns",
    title: "For Bigger Meltdowns",
    src: "<https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4>",
    poster: unsplash(5),
  },
  {
    id: "blazes",
    title: "For Bigger Blazes",
    src: "<https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4>",
    poster: unsplash(6),
  },
  {
    id: "fun",
    title: "For Bigger Fun",
    src: "<https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4>",
    poster: unsplash(7),
  },
  {
    id: "sintel",
    title: "Sintel (Trailer)",
    src: "<https://media.w3.org/2010/05/sintel/trailer_hd.mp4>",
    poster: unsplash(8),
  },
];

function unsplash(i: number) {
  const ids = [
    "photo-1500530855697-b586d89ba3ee",
    "photo-1520975934247-c8a3d77a3f3b",
    "photo-1495562569060-2eec283d3391",
    "photo-1499951360447-b19be8fe80f5",
    "photo-1519681393784-d120267933ba",
    "photo-1469474968028-56623f02e42e",
    "photo-1513151233558-d860c5398176",
    "photo-1517816743773-6e0fd518b4a6",
  ];
  return `https://images.unsplash.com/${
    ids[(i - 1) % ids.length]
  }?q=80&w=1600&auto=format&fit=crop`;
}

export default function HoverVideoGallery({
  items = demoItems,
  title = "Selected Works",
}: {
  items?: VideoItem[];
  title?: string;
}) {
  return (
    <main className="min-h-screen w-full  text-white">
      <div className="mx-auto max-w-6xl px-6 py-10">
        <div className="grid gap-8 sm:grid-cols-1 md:grid-cols-2">
          {items.map((v, i) => (
            <motion.div
              variants={animationVariants}
              initial={"initial"}
              animate={"animate"}
              custom={i}
            >
              <VideoCard key={v.id} item={v} />
            </motion.div>
          ))}
        </div>
      </div>
    </main>
  );
}

function VideoCard({ item }: { item: VideoItem }) {
  const videoRef = React.useRef<HTMLVideoElement | null>(null);
  const [loaded, setLoaded] = React.useState(false);
  const prefersReduced = usePrefersReducedMotion();

  const play = React.useCallback(async () => {
    const el = videoRef.current;
    if (!el) return;
    if (prefersReduced) return; // respect reduced motion
    try {
      await el.play();
    } catch {
      // Autoplay might be blocked; ignore
    }
  }, [prefersReduced]);

  const stop = React.useCallback(() => {
    const el = videoRef.current;
    if (!el) return;
    el.pause();
    // Reset to start so poster is shown next time
    try {
      el.currentTime = 0;
    } catch {}
  }, []);

  // Pause when card leaves viewport
  React.useEffect(() => {
    const el = videoRef.current;
    if (!el) return;
    const obs = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (!e.isIntersecting) stop();
        });
      },
      { threshold: 0.15 }
    );
    obs.observe(el);
    return () => obs.disconnect();
  }, [stop]);

  return (
    <figure className="group">
      {/* Media */}
      <div
        className="rounded-sm relative aspect-[16/9] overflow-hidden bg-black ring-1 ring-white/10 transition-transform duration-200 group-hover:scale-[1.01] hover:shadow-2xl hover:shadow-slate-500"
        onMouseEnter={play}
        onMouseLeave={stop}
        onFocus={play}
        onBlur={stop}
        tabIndex={0}
        aria-label={`${item.title} preview`}
      >
        <video
          ref={videoRef}
          className="absolute inset-0 h-full w-full object-cover"
          src={item.src}
          poster={item.poster}
          muted
          playsInline
          preload="metadata"
          onCanPlay={() => setLoaded(true)}
        />

        {/* subtle gradient + loading shimmer */}
        <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
        {!loaded && (
          <div className="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,rgba(255,255,255,0.03)_8%,rgba(255,255,255,0.06)_18%,rgba(255,255,255,0.03)_33%)] bg-[length:200%_100%]" />
        )}
      </div>
    </figure>
  );
}

function usePrefersReducedMotion() {
  const [reduced, setReduced] = React.useState(false);
  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onChange = () => setReduced(mq.matches);
    onChange();
    mq.addEventListener?.("change", onChange);
    return () => mq.removeEventListener?.("change", onChange);
  }, []);
  return reduced;
}