Last active
February 5, 2024 13:48
-
-
Save tol-is/7fed49525ba67df012130a7c165e9112 to your computer and use it in GitHub Desktop.
React - get scroll position
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 React, { useRef, useState } from "react"; | |
import { useScrollPosition } from "./use-scroll-position"; | |
import { normalize } from "./normalize"; | |
import "./styles.css"; | |
export default function App() { | |
const percentRef = useRef<HTMLDivElement>(null); | |
const p1Ref = useRef<HTMLParagraphElement>(null); | |
const bar1Ref = useRef<HTMLDivElement>(null); | |
const p2Ref = useRef<HTMLParagraphElement>(null); | |
const bar2Ref = useRef<HTMLDivElement>(null); | |
const p3Ref = useRef<HTMLParagraphElement>(null); | |
const bar3Ref = useRef<HTMLDivElement>(null); | |
const [activeStep, setActiveStep] = useState(-1); | |
const steps = 3; | |
const step = 1 / steps; | |
const handlePercentChange = (scrollPercent) => { | |
percentRef.current.innerHTML = scrollPercent; | |
const p1 = normalize(scrollPercent, step * 0, step * 1); | |
const p2 = normalize(scrollPercent, step * 1, step * 2); | |
const p3 = normalize(scrollPercent, step * 2, step * 3); | |
p1Ref.current.innerHTML = p1; | |
bar1Ref.current.style.transform = `scaleX(${p1})`; | |
p2Ref.current.innerHTML = p2; | |
bar2Ref.current.style.transform = `scaleX(${p2})`; | |
p3Ref.current.innerHTML = p3; | |
bar3Ref.current.style.transform = `scaleX(${p3})`; | |
setActiveStep(Math.min(Math.floor(scrollPercent / (1 / steps)), steps - 1)); | |
}; | |
const { ref } = useScrollPosition({ | |
offsetTop: 0, | |
offsetBottom: 0, | |
onChange: handlePercentChange, | |
}); | |
return ( | |
<main> | |
<section ref={ref} style={{ "--steps": steps } as React.CSSProperties}> | |
<article> | |
<div className="percent" ref={percentRef} /> | |
<div> | |
<p className="par" ref={p1Ref} /> | |
<div className="bar"> | |
<div ref={bar1Ref} /> | |
</div> | |
<p className="par" ref={p2Ref} /> | |
<div className="bar"> | |
<div ref={bar2Ref} /> | |
</div> | |
<p className="par" ref={p3Ref} /> | |
<div className="bar"> | |
<div ref={bar3Ref} /> | |
</div> | |
</div> | |
<div className="active">{activeStep}</div> | |
</article> | |
</section> | |
</main> | |
); | |
} |
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 { toPrecision } from "./precision"; | |
export const normalize = ( | |
value: number, | |
minFrom: number, | |
maxFrom: number, | |
minTo: number = 0, | |
maxTo: number = 1, | |
precision?: number = 3 | |
): number => { | |
// Ensure the value is within the source range | |
const clampedValue = Math.min(maxFrom, Math.max(minFrom, value)); | |
// Calculate the normalized value in the target range | |
const normalizedValue = | |
((clampedValue - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo; | |
return toPrecision(normalizedValue, precision); | |
}; |
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
{ | |
"name": "react-typescript", | |
"version": "1.0.0", | |
"description": "React and TypeScript example starter project", | |
"keywords": ["typescript", "react", "starter"], | |
"main": "src/index.tsx", | |
"dependencies": { | |
"loader-utils": "3.2.1", | |
"react": "18.2.0", | |
"react-dom": "18.2.0", | |
"react-scripts": "5.0.1", | |
"react-spring": "9.7.3" | |
}, | |
"devDependencies": { | |
"@types/react": "18.2.38", | |
"@types/react-dom": "18.2.15", | |
"typescript": "4.4.4" | |
}, | |
"scripts": { | |
"start": "react-scripts start", | |
"build": "react-scripts build", | |
"test": "react-scripts test --env=jsdom", | |
"eject": "react-scripts eject" | |
}, | |
"browserslist": [">0.2%", "not dead", "not ie <= 11", "not op_mini all"] | |
} |
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
export const toPrecision = (number: number, precision: number): number => { | |
const factor = 10 ** precision | |
return Math.round(number * factor) / factor | |
} |
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
body { | |
font-family: sans-serif; | |
background-color: #e5e5e5; | |
} | |
section { | |
position: relative; | |
margin: 100vh 0 100vh 0; | |
height: calc(var(--steps) * 100vh); | |
display: block; | |
background-color: #ff00cc; | |
} | |
.percent { | |
position: absolute; | |
top: 40px; | |
right: 40px; | |
font-weight: bold; | |
font-size: 28px; | |
z-index: 10; | |
} | |
article { | |
position: sticky; | |
top: 0; | |
height: 95vh; | |
padding: 20px; | |
background-color: #ffcc00; | |
display: grid; | |
grid-template-columns: 4fr 8fr; | |
} | |
.par { | |
font-size: 18px; | |
font-weight: bold; | |
line-height: 1; | |
margin: 10px 0; | |
} | |
.bar { | |
width: 200px; | |
height: 12px; | |
background-color: #fff; | |
} | |
.bar > div { | |
width: 200px; | |
height: 12px; | |
background-color: #000; | |
transform-origin: 0 0; | |
} | |
.active { | |
font-size: 172px; | |
font-weight: bold; | |
display: grid; | |
place-content: center; | |
} |
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
Show hidden characters
{ | |
"include": [ | |
"./src/**/*" | |
], | |
"compilerOptions": { | |
"strict": true, | |
"esModuleInterop": true, | |
"lib": [ | |
"dom", | |
"es2015" | |
], | |
"jsx": "react-jsx" | |
} | |
} |
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 { useCallback, useEffect, useRef } from "react"; | |
import { useInView, useIsomorphicLayoutEffect } from "@react-spring/web"; | |
import { toPrecision } from "./precision"; | |
type Bounds = { top: number; height: number; viewport: number }; | |
export type UseScrollPositionProps = { | |
/** | |
* The offset from the top of the section to trigger the callback. Represents a percentage of the viewport height. | |
* | |
* @defaultValue 0 | |
*/ | |
offsetTop?: number; | |
/** | |
* The offset from the bottom of the section to trigger the callback. Represents a percentage of the viewport height. | |
* | |
* @defaultValue 0 | |
*/ | |
offsetBottom?: number; | |
/** | |
* The precision of the percentage position. | |
* | |
* @defaultValue 3 | |
*/ | |
precision?: number; | |
/** | |
* The callback to be called when the percentage position changes. The percentage position is a number between 0 and 1. | |
*/ | |
onChange: (scrollPercent: number) => void; | |
}; | |
export const useScrollPosition = (props: UseScrollPositionProps) => { | |
const { offsetTop = 0, offsetBottom = 0, precision = 3, onChange } = props; | |
const [ref, inView] = useInView(); | |
const enabledRef = useRef<boolean>(false); | |
const rafRef = useRef<number>(0); | |
const scrollYRef = useRef<number>(-1); | |
const boundsRef = useRef<Bounds>({ top: 0, height: 0, viewport: 0 }); | |
const animate = useCallback(() => { | |
const scrollY = window.scrollY; | |
if (scrollY !== scrollYRef.current) { | |
const { top, height, viewport } = boundsRef.current; | |
const adjustedTop = top - offsetTop * viewport; | |
const adjustedBottom = top + height - (1 - offsetBottom) * viewport; | |
let percentage; | |
if (scrollY < adjustedTop) { | |
// Above the container | |
percentage = 0; | |
} else if (scrollY > adjustedBottom) { | |
// below the container | |
percentage = 1; | |
} else { | |
// compute percentage position | |
percentage = toPrecision( | |
(scrollY - adjustedTop) / (adjustedBottom - adjustedTop), | |
precision | |
); | |
} | |
onChange && onChange(percentage); | |
scrollYRef.current = window.scrollY; | |
} | |
if (enabledRef.current === true) { | |
rafRef.current = requestAnimationFrame(animate); | |
} | |
}, [props]); | |
const getSectionBounds = useCallback(() => { | |
const viewportHeight = window.innerHeight; | |
const sectionHeight = ref.current.offsetHeight; | |
const sectionTop = ref.current.offsetTop; | |
boundsRef.current = { | |
top: sectionTop, | |
height: sectionHeight, | |
viewport: viewportHeight, | |
}; | |
}, [ref]); | |
useEffect(() => { | |
if (inView) { | |
getSectionBounds(); | |
enabledRef.current = true; | |
rafRef.current = requestAnimationFrame(animate); | |
} else { | |
enabledRef.current = false; | |
cancelAnimationFrame(rafRef.current); | |
} | |
return () => { | |
cancelAnimationFrame(rafRef.current); | |
}; | |
}, [inView, animate, getSectionBounds]); | |
useIsomorphicLayoutEffect(() => { | |
if (typeof window === "undefined") return; | |
window.addEventListener("resize", getSectionBounds); | |
return () => { | |
window.removeEventListener("resize", getSectionBounds); | |
}; | |
}, []); | |
useEffect(() => { | |
return () => { | |
enabledRef.current = false; | |
cancelAnimationFrame(rafRef.current); | |
}; | |
}, []); | |
return { ref }; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment