Skip to content

Instantly share code, notes, and snippets.

@fluid-design-io
Last active October 9, 2022 21:34
Show Gist options
  • Save fluid-design-io/4b3460c9ec4d84ff722bb699a945629a to your computer and use it in GitHub Desktop.
Save fluid-design-io/4b3460c9ec4d84ff722bb699a945629a to your computer and use it in GitHub Desktop.
A canvas based sequence scroll animation player built with framer motion.
// From https://github.com/theodorusclarence/ts-nextjs-tailwind-starter
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merge classes with tailwind-merge with clsx full feature */
export default function clsxm(...classes: ClassValue[]) {
return twMerge(clsx(...classes));
}
import { SequenceScroll } from "@/lib/SequenceScroll";
export const IndexPage = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [videoProgress, setVideoProgress] = useState<MotionValue<number>>(
motionValue(0)
);
return (
<>
<SequenceScroll
className='relative'
canvasClassName='w-auto mx-auto'
progress={(p) => setVideoProgress(p)}
ref={containerRef}
>
<p>Any additional content can be put here</p>
</SequenceScroll>
</>
);
};
import { useTheme } from "@/lib/ThemeContext";
import { MotionValue, useScroll, useSpring } from "framer-motion";
import React, {
forwardRef,
MutableRefObject,
useEffect,
useId,
useRef,
useState,
} from "react";
import clsxm from "./clsxm";
type VideoScrollProps = {
baseUrl?: string;
/**
* Width of the video
* @defaultValue 1440
*/
width?: number;
/**
* Height of the video
* @defaultValue 810
* @type {number}
*/
height?: number;
/**
* The number of frames video has
* @defaultValue 120
* @type {number}
*/
frameCount?: number;
/**
* progress is a function that returns the current progress of the video
* @type {number{0-1}}
* @param {number} progress
* @returns {void}
*/
progress?: (progress: MotionValue<number>) => void;
className?: string;
/**
* Framer offset prop
* @type `ScrollOffset`
*/
offset?: any;
canvasClassName?: string;
children?: React.ReactNode;
};
export const SequenceScroll = forwardRef(
(
{
baseUrl = "/images/sequence/fluid-design",
width = 1440,
height = 810,
frameCount = 120,
progress,
className,
canvasClassName,
offset = ["start start", "end end"],
children,
}: VideoScrollProps,
/**
* If ref is passed, it will be used to get the container element as the parent of the canvas
* to calculate the scroll offset
* otherwise, it will use the ref of the canvas element
*/
ref: MutableRefObject<HTMLDivElement>
) => {
const id = useId();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [images, setImages] = useState<HTMLImageElement[]>([]);
const { mode } = useTheme(); // Get the light/dark mode of the site
const { scrollYProgress } = useScroll({
target: ref ? ref : canvasRef,
offset,
});
const modeString = mode === "dark" ? "dark" : "light";
const videoProgress = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
let frameIndex = 0;
const update = () => {
// image width and height is 1440 * 810
const canvas = canvasRef.current;
if (!canvas && images.length !== frameCount) return;
const context = canvas.getContext("2d");
// Set the canvas to the same dimensions as the image, but if window is smaller, use window size instead
const ratio = width / height;
const windowWidth = window.innerWidth;
const canvasWidth = windowWidth > width ? width : windowWidth;
const canvasHeight = canvasWidth / ratio;
const pixelRatio = window.devicePixelRatio;
canvas.width = canvasWidth * pixelRatio;
canvas.height = canvasHeight * pixelRatio;
frameIndex = Math.min(
Math.max(0, Math.floor(videoProgress.get() * frameCount)),
frameCount - 1
);
const image = images[frameIndex];
// console.log("progess", scrollYProgress.get(), frameIndex, image?.src);
if (!image) return;
context.clearRect(0, 0, canvas.width, canvas.height);
// draw image to canvas, the image is 1440 * 810
context.drawImage(image, 0, 0, canvas.width, canvas.height);
progress && progress(videoProgress);
};
useEffect(() => {
if (typeof window !== "undefined") {
const imageArray = [];
for (let i = 0; i < frameCount; i++) {
const image = new Image();
image.src = `${baseUrl}/${modeString}/${(i + 1)
.toString()
.padStart(4, "0")}.webp`;
imageArray.push(image);
}
setImages(imageArray);
}
}, [mode]);
useEffect(() => {
return videoProgress.onChange(() => update());
});
return (
<div ref={ref} className={clsxm(className)}>
{children}
<canvas
id={id}
className={clsxm(
"pointer-events-none h-auto w-auto max-w-full",
canvasClassName
)}
ref={canvasRef}
/>
</div>
);
}
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment