Skip to content

Instantly share code, notes, and snippets.

@tol-is
Last active February 5, 2024 13:48
Show Gist options
  • Save tol-is/7fed49525ba67df012130a7c165e9112 to your computer and use it in GitHub Desktop.
Save tol-is/7fed49525ba67df012130a7c165e9112 to your computer and use it in GitHub Desktop.
React - get scroll position
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>
);
}
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);
};
{
"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"]
}
export const toPrecision = (number: number, precision: number): number => {
const factor = 10 ** precision
return Math.round(number * factor) / factor
}
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;
}
{
"include": [
"./src/**/*"
],
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"lib": [
"dom",
"es2015"
],
"jsx": "react-jsx"
}
}
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