Last active
June 20, 2024 21:07
-
-
Save perrysmotors/9622d1aa0be45fef3a266d305cb4c975 to your computer and use it in GitHub Desktop.
Overrides to create scroll interactions on Framer sites
This file contains hidden or 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 type { ComponentType } from "react" | |
import { useState, useEffect } from "react" | |
import type { MotionValue, Transition } from "framer-motion" | |
import { | |
useScroll, | |
useVelocity, | |
useTransform, | |
useMotionValue, | |
animate, | |
} from "framer-motion" | |
// The following overrides are for creating scroll effects on web pages | |
export function withParallax(Component): ComponentType { | |
const speed = 1 | |
return (props: any) => { | |
const { scrollY } = useScroll() | |
const x = useTransform(scrollY, (value) => -value * speed) // scrolling down translates left | |
return <Component {...props} style={{ ...props.style, x }} /> | |
} | |
} | |
// Scrub through a video or drive a Lottie animation by scrolling | |
export function withScrolledProgress(Component): ComponentType { | |
const startY = 0 // scroll position when animation starts | |
const distance = 1000 // scroll distance after which animation ends | |
const endY = startY + distance | |
return (props) => { | |
const { scrollY } = useScroll() | |
const progress = useTransform(scrollY, [startY, endY], [0, 1]) | |
return <Component {...props} progress={progress} /> | |
} | |
} | |
export function withScrollLinkedValue(Component): ComponentType { | |
// Value being driven by scrolling (e.g. height) | |
const initialValue = 200 | |
const finalValue = 100 | |
const speed = 1 | |
const scrollDistance = (initialValue - finalValue) / speed | |
const startY = 150 // scroll position when transition starts | |
const endY = startY + scrollDistance | |
return (props: any) => { | |
const { scrollY } = useScroll() | |
const scrollOutput = useTransform( | |
scrollY, | |
[startY, startY, endY, endY], | |
[initialValue, initialValue, finalValue, finalValue], | |
{ | |
clamp: false, | |
} | |
) | |
return <Component {...props} style={{ ...props.style, height: scrollOutput }} /> | |
} | |
} | |
export function withScrollToggledVariant(Component): ComponentType { | |
const thresholdY = 500 // set the scroll position where you want the component to switch | |
return (props) => { | |
const { scrollY } = useScroll() | |
const [isPastThreshold, setIsPastThreshold] = useState(false) | |
useEffect( | |
() => | |
scrollY.onChange((latest) => | |
setIsPastThreshold(latest > thresholdY) | |
), | |
[] | |
) | |
return ( | |
<Component | |
{...props} | |
variant={isPastThreshold ? "Second" : "First"} // variants to animate between | |
/> | |
) | |
} | |
} | |
export function withSlideOutOnScrollUp(Component): ComponentType { | |
const slideDistance = 100 // if we are sliding out a nav bar at the top of the screen, this will be it's height | |
const threshold = 500 // only slide it back when scrolling back at velocity above this positive (or zero) value | |
return (props) => { | |
const { scrollY } = useScroll() | |
const scrollVelocity = useVelocity(scrollY) | |
const [isScrollingBack, setIsScrollingBack] = useState(false) | |
const [isAtTop, setIsAtTop] = useState(true) // true if the page is not scrolled or fully scrolled back | |
const [isInView, setIsInView] = useState(true) | |
useEffect( | |
() => | |
scrollVelocity.onChange((latest) => { | |
if (latest > 0) { | |
setIsScrollingBack(false) | |
return | |
} | |
if (latest < -threshold) { | |
setIsScrollingBack(true) | |
return | |
} | |
}), | |
[] | |
) | |
useEffect( | |
() => scrollY.onChange((latest) => setIsAtTop(latest <= 0)), | |
[] | |
) | |
useEffect( | |
() => setIsInView(isScrollingBack || isAtTop), | |
[isScrollingBack, isAtTop] | |
) | |
return ( | |
<Component | |
{...props} | |
animate={{ y: isInView ? 0 : -slideDistance }} | |
transition={{ duration: 0.2, delay: 0.25, ease: "easeInOut" }} | |
/> | |
) | |
} | |
} | |
export function withScrollTriggeredStates(Component): ComponentType { | |
const scrollYRange = [0, 1000, 1600] // scroll positions that trigger the animation | |
const outputRange = ["First", "Second", "Third"] // list of variants to animate between | |
return (props) => { | |
const state = useScrollTriggeredState(scrollYRange, outputRange) | |
return <Component {...props} variant={state} /> | |
} | |
} | |
// Trigger a state change when each layer with a <section> tag reaches the top of the page | |
// You can apply a <section> tag to a layer through the 'Accessibility' property controls | |
export function withSectionTriggeredStates(Component): ComponentType { | |
const outputRange = ["First", "Second", "Third"] // list of variants to animate between | |
return (props) => { | |
const { scrollY } = useScroll() | |
const [state, setState] = useState(outputRange[0]) | |
useEffect(() => { | |
const scrollYRange = getSectionPositions() | |
scrollY.onChange((latest) => { | |
const output = getCorrespondingItem( | |
latest, | |
scrollYRange, | |
outputRange | |
) | |
setState(output) | |
}) | |
}, []) | |
return <Component {...props} variant={state} /> | |
} | |
} | |
export function withScrollTriggeredAnimation(Component): ComponentType { | |
const scrollYRange = [0, 1000, 1600] // scroll positions that trigger the animation | |
const outputRange = ["#8E47BA", "#000AFF", "#FF0000"] // list of values to animate to | |
// customise the transition | |
const transition: Transition = { | |
type: "tween", | |
duration: 1, | |
ease: "easeInOut", | |
} | |
return (props: any) => { | |
const animatedValue = useMotionValue(outputRange[0]) | |
const { scrollY } = useScroll() | |
const scrollOutput = useSteppedTransform( | |
scrollY, | |
scrollYRange, | |
outputRange | |
) | |
useEffect( | |
() => | |
scrollOutput.onChange( | |
(latest) => animate(animatedValue, latest, transition) // remove transition to use default | |
), | |
[] | |
) | |
return ( | |
<Component {...props} style={{ ...props.style, backgroundColor: animatedValue }} /> // override value you want to animate | |
) | |
} | |
} | |
// Trigger an animation when each layer with a <section> tag reaches the top of the page | |
// You can apply a <section> tag to a layer through the 'Accessibility' property controls | |
export function withSectionTriggeredAnimation(Component): ComponentType { | |
const outputRange = ["#FFEE66", "#000AFF", "#FF0000"] // list of values to animate to | |
// customise the transition | |
const transition: Transition = { | |
type: "tween", | |
duration: 1, | |
ease: "easeInOut", | |
} | |
return (props: any) => { | |
const animatedValue = useMotionValue(outputRange[0]) | |
const handleSectionChange = (latest) => | |
animate(animatedValue, outputRange[latest], transition) // remove transition to use default | |
useSectionTrigger(handleSectionChange) | |
return ( | |
<Component {...props} style={{ ...props.style, backgroundColor: animatedValue }} /> // override value you want to animate | |
) | |
} | |
} | |
// Apply the current scroll target to the URL displayed in the web browser | |
// You can apply a scroll target to a layer through the 'Scroll Target' property controls | |
export function withScrollTargetHistory(Component): ComponentType { | |
return (props) => { | |
const { scrollY } = useScroll() | |
const scrollOutput = useMotionValue("#") | |
const handleTargetChange = (latest) => | |
history.replaceState(null, "", latest) | |
useEffect(() => { | |
const { scrollYRange, outputRange } = getScrollTargets() | |
scrollY.onChange((latest) => { | |
const index = getMatchingIndex(latest, scrollYRange) | |
if (scrollOutput.get() !== outputRange[index]) { | |
scrollOutput.set(outputRange[index]) | |
} | |
}) | |
}, []) | |
useEffect(() => scrollOutput.onChange(handleTargetChange), []) | |
return <Component {...props} /> | |
} | |
} | |
// Custom hooks | |
function useSteppedTransform( | |
value: MotionValue, | |
inputRange: number[], | |
outputRange: any[] | |
) { | |
return useTransform(value, (value) => | |
getCorrespondingItem(value, inputRange, outputRange) | |
) | |
} | |
function useScrollTriggeredState(inputRange: number[], outputRange: any[]) { | |
const { scrollY } = useScroll() | |
const [state, setState] = useState(outputRange[0]) | |
useEffect( | |
() => | |
scrollY.onChange((latest) => | |
setState(getCorrespondingItem(latest, inputRange, outputRange)) | |
), | |
[] | |
) | |
return state | |
} | |
function useSectionTrigger(handleSectionChange) { | |
const scrollOutput = useMotionValue(0) | |
const { scrollY } = useScroll() | |
useEffect(() => { | |
const scrollYRange = getSectionPositions() | |
scrollY.onChange((latest) => { | |
const index = getMatchingIndex(latest, scrollYRange) | |
if (scrollOutput.get() !== index) { | |
scrollOutput.set(index) | |
} | |
}) | |
}, []) | |
useEffect(() => scrollOutput.onChange(handleSectionChange), []) | |
} | |
// Functions | |
function getMatchingIndex(value, array) { | |
let found = array.findIndex((el) => el > value) | |
switch (found) { | |
case 0: | |
return 0 | |
break | |
case -1: | |
return array.length - 1 | |
break | |
default: | |
return found - 1 | |
} | |
} | |
function getCorrespondingItem( | |
value: number, | |
inputRange: number[], | |
outputRange: any[] | |
) { | |
const inputIndex = getMatchingIndex(value, inputRange) | |
const outputIndex = | |
inputIndex > outputRange.length - 1 | |
? outputRange.length - 1 | |
: inputIndex | |
return outputRange[outputIndex] | |
} | |
function getSectionPositions() { | |
const elements = Array.from(document.querySelectorAll("section")) | |
const positions = elements | |
.map((element) => { | |
return element.getBoundingClientRect().top + window.scrollY | |
}) | |
.sort((a, b) => a - b) | |
if (positions[0] === 0) { | |
return positions | |
} else { | |
return [0, ...positions] | |
} | |
} | |
function getScrollTargets() { | |
const elements = Array.from(document.querySelectorAll('[id]:not([id=""])')) | |
const targets = elements | |
.map((element) => { | |
return { | |
y: element.getBoundingClientRect().top + window.scrollY, | |
target: `#${element.id}`, | |
} | |
}) | |
.sort((a, b) => a.y - b.y) | |
const inputs = targets.map((target) => target.y) | |
const outputs = targets.map((target) => target.target) | |
if (inputs[0] === 0) { | |
outputs[0] = "#" | |
return { scrollYRange: inputs, outputRange: outputs } | |
} else { | |
return { | |
scrollYRange: [0, ...inputs], | |
outputRange: ["#", ...outputs], | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@pizza0502 withParallax() can be used to translate an element horizontally when you scroll vertically. If the position of the element is 'fixed' then the element won't scroll vertically.