A React component that renders a sequence of images on a canvas element, allowing users to scroll through them as if they were frames in a video. Ideal for creating interactive storytelling experiences or showcasing product features.
0% Complete
ScrollImageSequence.tsx (Scroll Sequence Component)
"use client";
import { useEffect, useRef, useState } from "react";
import { Box, Container, Title, Text, Stack, Loader, Center } from "@mantine/core";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
// Register ScrollTrigger plugin
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger);
}
interface ScrollImageSequenceProps {
// Base URL for S3 bucket - update this with your actual S3 URL
baseUrl?: string;
// Total number of frames (default: 240 for frame_0001 to frame_0240)
totalFrames?: number;
// Image file extension
imageExtension?: string;
// Optional title and description
title?: string;
description?: string;
// Object fit mode: "cover" fills canvas (may crop), "contain" shows full image (may letterbox)
objectFit?: "cover" | "contain";
// Canvas height - can be viewport-based or aspect-ratio based
canvasHeight?: string;
// Aspect ratio for canvas (e.g., 16/9 for widescreen, null for full viewport)
aspectRatio?: number | null;
}
const ScrollImageSequence = ({
baseUrl = "",
totalFrames = 240,
imageExtension = "webp",
title = "Scroll to Explore",
description = "Experience smooth 3D-like animation as you scroll",
objectFit = "cover",
// canvasHeight = "100vh",
aspectRatio = null,
}: ScrollImageSequenceProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Combined into one state object to avoid cascading setState calls in image load callbacks
const [imageLoadState, setImageLoadState] = useState<{
imagesLoaded: boolean;
loadingProgress: number;
}>({ imagesLoaded: false, loadingProgress: 0 });
const imagesLoaded = imageLoadState.imagesLoaded;
const loadingProgress = imageLoadState.loadingProgress;
const imagesRef = useRef<HTMLImageElement[]>([]);
const frameIndexRef = useRef({ frame: 0 });
// Preload all images
useEffect(() => {
const images: HTMLImageElement[] = [];
let loadedCount = 0;
const frameCount = totalFrames;
// Create image elements for all frames
for (let i = 1; i <= frameCount; i++) {
const img = new Image();
const frameNumber = String(i).padStart(4, "0"); // Pad to 4 digits: 0001, 0002, etc.
img.src = `${baseUrl}/frame_${frameNumber}.${imageExtension}`;
img.onload = () => {
loadedCount++;
const progress = Math.round((loadedCount / frameCount) * 100);
const allLoaded = loadedCount === frameCount;
setImageLoadState({
imagesLoaded: allLoaded,
loadingProgress: progress,
});
};
img.onerror = () => {
console.error(`Failed to load image: frame_${frameNumber}`);
loadedCount++;
const progress = Math.round((loadedCount / frameCount) * 100);
const allLoaded = loadedCount === frameCount;
setImageLoadState({
imagesLoaded: allLoaded,
loadingProgress: progress,
});
};
images.push(img);
}
imagesRef.current = images;
return () => {
// Cleanup
imagesRef.current = [];
};
}, [baseUrl, totalFrames, imageExtension]);
// Setup canvas and GSAP ScrollTrigger animation
useEffect(() => {
if (!imagesLoaded || !canvasRef.current || !containerRef.current) return;
const canvas = canvasRef.current;
const context = canvas.getContext("2d");
if (!context) return;
const images = imagesRef.current;
// Set canvas dimensions
const setCanvasSize = () => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const isMobile = window.innerWidth < 768; // Tailwind md breakpoint
// On mobile: prioritize viewport height, calculate width from aspect ratio
if (isMobile) {
// Mobile: fill viewport height and calculate width to maintain aspect ratio
canvas.height = window.innerHeight;
if (aspectRatio) {
canvas.width = window.innerHeight * aspectRatio;
} else {
// If no aspect ratio, use a reasonable default (16:9)
canvas.width = window.innerHeight * (16 / 9);
}
} else {
// Desktop: use container width and calculate height based on aspect ratio
canvas.width = rect.width;
if (aspectRatio) {
canvas.height = rect.width / aspectRatio;
} else {
canvas.height = rect.height;
}
}
// Render initial frame
renderFrame(0);
};
// Render specific frame to canvas
const renderFrame = (index: number) => {
if (!images[index]) return;
const img = images[index];
// Clear canvas
context.clearRect(0, 0, canvas.width, canvas.height);
// Calculate scaling to fit canvas while maintaining aspect ratio
// Use Math.min for "contain" (shows full image), Math.max for "cover" (fills canvas)
const scale =
objectFit === "contain"
? Math.min(canvas.width / img.width, canvas.height / img.height)
: Math.max(canvas.width / img.width, canvas.height / img.height);
const x = (canvas.width - img.width * scale) / 2;
const y = (canvas.height - img.height * scale) / 2;
// Draw image centered and scaled
context.drawImage(img, x, y, img.width * scale, img.height * scale);
};
setCanvasSize();
window.addEventListener("resize", setCanvasSize);
// Create GSAP ScrollTrigger animation
// Use rAF to ensure layout is fully settled before GSAP measures positions.
// Without this, newly added sections above this component can cause GSAP
// to calculate trigger positions from a partially-rendered layout.
const rafId = requestAnimationFrame(() => {
ScrollTrigger.refresh();
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "bottom bottom",
scrub: 1, // Smooth scrubbing effect
pin: canvas,
pinSpacing: true,
},
});
// Animate frame index based on scroll
tl.to(frameIndexRef.current, {
frame: images.length - 1,
snap: "frame",
ease: "none",
onUpdate: () => {
const frameIndex = Math.round(frameIndexRef.current.frame);
renderFrame(frameIndex);
},
});
});
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", setCanvasSize);
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imagesLoaded]);
if (!imagesLoaded) {
return (
<Container size="xl" py={{ base: 60, md: 80 }}>
<Center style={{ minHeight: "400px" }}>
<Stack gap="md" align="center">
<Loader size="xl" />
<Title order={3} style={{ color: "var(--dashboard-main-text-primary, #F8FAFC)" }}>
Loading Experience…
</Title>
<Text size="lg" style={{ color: "var(--dashboard-main-text-secondary, #94A3B8)" }}>
{loadingProgress}% Complete
</Text>
</Stack>
</Center>
</Container>
);
}
return (
<Box
ref={containerRef}
style={{
position: "relative",
height: "300vh", // 3x viewport height for scroll distance
background: "var(--dashboard-card-background, rgba(15, 23, 42, 0.4))",
overflow: "hidden", // Hide overflowing width on mobile
}}
>
{/* Title overlay */}
<Box
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 10,
textAlign: "center",
pointerEvents: "none",
backgroundColor: "rgba(0,0,0,0.4)",
borderRadius: "20px",
width: "90%",
maxWidth: "800px",
}}
p={{ base: "md", sm: "lg", md: "xl" }}
>
<Stack gap="md">
<Title
order={1}
style={{
color: "white",
textShadow: "0 2px 10px rgba(0, 0, 0, 0.5)",
fontSize: "clamp(1.5rem, 5vw, 3rem)",
}}
>
{title}
</Title>
{description && (
<Text
style={{
color: "white",
textShadow: "0 2px 10px rgba(0, 0, 0, 0.5)",
fontSize: "clamp(0.875rem, 3vw, 1.25rem)",
}}
>
{description}
</Text>
)}
</Stack>
</Box>
{/* Canvas for image sequence */}
<canvas
ref={canvasRef}
style={{
position: "sticky",
top: 0,
left: 0,
width: "100%",
height: "100vh",
objectFit: "cover",
display: "block",
zIndex: 9,
}}
/>
</Box>
);
};
export default ScrollImageSequence;