Last active
October 9, 2022 21:34
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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)); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
</> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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