Nghiên cứu class scrollingEffect 👌
Đây có source html, js, css thuần
Source
const MathUtils = {
map: (x: number, a: number, b: number, c: number, d: number) => ((x - a) * (d - c)) / (b - a) + c,
lerp: (a: number, b: number, n: number) => (1 - n) * a + n * b,
getRandomFloat: (min: number, max: number) => parseFloat((Math.random() * (max - min) + min).toFixed(2))
};
Ý nghĩa của phương thức map
trong MathUtils
map
trong MathUtils
Phương thức map
có nhiệm vụ chuyển đổi giá trị x
từ một khoảng [a, b] sang một khoảng mới [c, d], đảm bảo tỷ lệ (scale) giữa hai khoảng không đổi.
Ví dụ thực tế để dễ hiểu:
✅ Bài toán:
Bạn có một giá trị nhiệt độ đo được trong khoảng [0, 100] °C.
Bạn muốn chuyển nó sang khoảng [32, 212] °F (nhiệt độ theo độ F).
➡️ Công thức map sẽ thực hiện điều đó.
Cách hoạt động:
Giả sử:
x = 50
(giá trị cần map)a = 0
,b = 100
(khoảng cũ)c = 32
,d = 212
(khoảng mới)
Thay vào:
bashCopyEditmap(50, 0, 100, 32, 212)
= ((50 - 0) * (212 - 32)) / (100 - 0) + 32
= (50 * 180) / 100 + 32
= (9000 / 100) + 32
= 90 + 32
= 122
✅ Kết quả: 50°C = 122°F (đúng theo công thức chuyển đổi nhiệt độ).
Tóm tắt ngắn gọn:
🔹 map(x, a, b, c, d) ➡️ Chuyển đổi x từ khoảng [a, b] sang [c, d] theo tỷ lệ tương ứng.
Ứng dụng trong lập trình đồ họa, canvas, animation:
Chuyển đổi toạ độ giữa các hệ trục (canvas sang window, percent sang pixel).
Scale giá trị tốc độ, thời gian, độ mờ opacity từ range logic sang range hiển thị.
Map giá trị input (ví dụ mouseX từ [0, windowWidth]) sang giá trị output (ví dụ rotation từ [-45°, +45°]).
Nếu bạn cần các ví dụ cụ thể trong dự án animation, GSAP, hoặc Three.js sắp tới, cho tôi biết để tôi chuẩn bị snippet minh hoạ phù hợp cho workflow học và build của bạn.
Example 1: Áp dụng cho reactjs (áp dụng với background)


Tham số background: true


src\scrollingEffect.ts
import imagesLoaded from 'imagesloaded';
interface RenderedStyle {
previous: number;
current: number;
ease: number;
fromValue?: number;
setValue: () => number;
}
interface ItemProps {
height: number;
top: number;
}
const MathUtils = {
map: (x: number, a: number, b: number, c: number, d: number) => ((x - a) * (d - c)) / (b - a) + c,
lerp: (a: number, b: number, n: number) => (1 - n) * a + n * b,
getRandomFloat: (min: number, max: number) => parseFloat((Math.random() * (max - min) + min).toFixed(2))
};
/**
* !class Item
*/
let docScroll = 0;
let lastScroll = 0;
/**
* class Item!
*/
/**
* !class SmoothScroll
*/
let scrollingSpeed = 0;
/**
* class SmoothScroll!
*/
let winsize: { width: number; height: number } = { width: 0, height: 0 }; // Initialize to prevent undefined access
const calcWinsize = () => winsize = { width: window.innerWidth, height: window.innerHeight };
calcWinsize();
// and recalculate on resize
window.addEventListener('resize', calcWinsize);
class Item {
// Step A1
DOM: {
el: HTMLElement;
image?: HTMLImageElement;
imageWrapper?: HTMLElement;
title?: HTMLElement | null; // Allow for null
deco?: HTMLElement | null; // Allow for null
};
// Step A2
renderedStyles: {
imageScale: RenderedStyle;
titleTranslationY: RenderedStyle;
decoTranslationY: RenderedStyle;
};
props!: ItemProps; // Definite assignment assertion
// Step A5
observer: IntersectionObserver;
// Step A6
isVisible = false;
// Step B2
insideViewport = false;
constructor(el: HTMLElement) {
this.DOM = {
el
};
this.DOM.image = this.DOM.el.querySelector(".content__item-img") as HTMLImageElement;
this.DOM.imageWrapper = this.DOM.image?.parentElement || undefined;
this.DOM.title = this.DOM.el.querySelector(".content__item-title");
this.DOM.deco = this.DOM.el.querySelector('.content__item-decobar');
this.renderedStyles = {
imageScale: {
previous: 0,
current: 0,
ease: 0.1,
setValue: () => {
const fromValue = 1;
const toValue = 1.5;
const val = MathUtils.map(this.props.top - docScroll, winsize.height, -1 * this.props.height, fromValue, toValue); // Chuyển đổi x từ khoảng [a, b] sang [c, d] theo tỷ lệ tương ứng.
return Math.max(Math.min(val, toValue), fromValue);
}
},
titleTranslationY: {
previous: 0,
current: 0,
ease: 0.1,
fromValue: MathUtils.getRandomFloat(30, 400),
setValue: () => {
const fromValue = this.renderedStyles.titleTranslationY.fromValue!;
const toValue = -1 * fromValue;
const val = MathUtils.map(this.props.top - docScroll, winsize.height, -1 * this.props.height, fromValue, toValue);
return fromValue < 0 ? Math.min(Math.max(val, fromValue), toValue) : Math.max(Math.min(val, fromValue), toValue);
}
},
decoTranslationY: {
previous: 0,
current: 0,
ease: 0.1,
setValue: () => {
const fromValue = -600;
const toValue = 600;
const val = MathUtils.map(this.props.top - docScroll, winsize.height, -1 * this.props.height, fromValue, toValue);
return Math.min(Math.max(val, fromValue), toValue);
}
}
}
// Step A3
this.getSize();
// Step A4
this.update();
this.observer = new IntersectionObserver((entries) => {
entries.forEach(
(entry) => (this.isVisible = entry.intersectionRatio > 0)
);
});
this.observer.observe(this.DOM.el);
this.initEvents();
}
getSize() {
const rect = this.DOM.el.getBoundingClientRect();
this.props = {
height: rect.height,
top: docScroll + rect.top,
};
}
update() {
for (const key in this.renderedStyles) {
const style = this.renderedStyles[key as keyof typeof this.renderedStyles];
style.current = style.previous = style.setValue();
}
this.layout();
}
resize() {
this.getSize();
this.update();
}
layout() {
if (this.DOM.title) {
const y = this.renderedStyles.titleTranslationY.previous;
this.DOM.title.style.transform = `translate3d(0,${y}px,0)`;
}
if (this.DOM.image) {
const scale = this.renderedStyles.imageScale.previous;
this.DOM.image.style.transform = `scale3d(${scale}, ${scale}, 1)`;
}
}
render() {
for (const key in this.renderedStyles) {
const style = this.renderedStyles[key as keyof typeof this.renderedStyles];
style.current = style.setValue();
style.previous = MathUtils.lerp(style.previous, style.current, style.ease);
}
this.layout();
}
initEvents() {
window.addEventListener("resize", () => this.resize());
}
}
class SmoothScroll {
DOM: {
body: HTMLElement;
main: HTMLElement;
scrollable: HTMLElement;
content: HTMLElement;
};
items: Item[];
renderedStyles: {
translationY: RenderedStyle;
skew: RenderedStyle;
};
constructor() {
this.DOM = {
body: document.querySelector("body")!,
main: document.querySelector("main")!,
scrollable: document.querySelector("div[data-scroll]")!,
content: document.querySelector(".content")!,
};
this.items = Array.from(this.DOM.content.querySelectorAll(".content__item")).map(
(item) => new Item(item as HTMLElement)
);
this.renderedStyles = {
translationY: {
previous: 0,
current: 0,
ease: 0.1,
setValue: () => docScroll,
},
skew: {
previous: 0,
current: 0,
ease: 0.1,
setValue: () => {
const toValue = 30;
const fromValue = 0;
const val = Math.max(Math.min(MathUtils.map(scrollingSpeed, 20, 100, fromValue, toValue), toValue), fromValue)
return this.renderedStyles.translationY.previous < docScroll ? val : -1 * val;
}
}
}
// Step B1
this.setSize();
this.update();
this.style();
this.initEvents();
requestAnimationFrame(() => this.render());
}
setSize() {
if (this.DOM.body && this.DOM.scrollable) { // Check if body and scrollable are not null
this.DOM.body.style.height = `${this.DOM.scrollable.scrollHeight}px`;
} else {
console.warn("Body or scrollable element is null! Cannot set height.");
}
}
style() {
Object.assign(this.DOM.main.style, {
position: "fixed",
width: "100%",
height: "100%",
top: "0",
left: "0",
overflow: "hidden",
});
}
update() {
for (const key in this.renderedStyles) {
const style = this.renderedStyles[key as keyof typeof this.renderedStyles];
style.current = style.previous = style.setValue();
}
this.layout();
}
layout() {
const y = -1 * this.renderedStyles.translationY.previous;
this.DOM.scrollable.style.transform = `translate3d(0, ${y}px, 0)`;
// this.DOM.scrollable.style.transform = `translate3d(0,${-1*this.renderedStyles.translationY.previous}px,0) skewY(${this.renderedStyles.skew.previous}deg)`;
}
initEvents() {
window.addEventListener("resize", () => this.setSize());
}
render() {
scrollingSpeed = Math.abs(docScroll - lastScroll);
lastScroll = docScroll;
for (const key in this.renderedStyles) {
const style = this.renderedStyles[key as keyof typeof this.renderedStyles];
style.current = style.setValue();
style.previous = MathUtils.lerp(style.previous, style.current, style.ease);
}
this.layout();
for (const item of this.items) {
if (item.isVisible) {
if (item.insideViewport) {
item.render();
} else {
item.insideViewport = true;
item.update();
}
} else {
item.insideViewport = false;
}
}
requestAnimationFrame(() => this.render());
}
}
export const getScrollPage = () => {
const getPageYScroll = () => {
docScroll = window.scrollY || document.documentElement.scrollTop;
};
window.addEventListener("scroll", getPageYScroll);
// ... (rest of Item, SmoothScroll classes and logic)
const preloadImages = (): Promise<void> => {
return new Promise((resolve) => {
imagesLoaded(
document.querySelectorAll(".content__item-img"),
{ background: true },
(instance) => {
// Your logic here, using the instance of ImagesLoaded
resolve(); // Call resolve after handling the loaded images
}
);
});
};
preloadImages().then(() => {
document.body.classList.remove("loading");
getPageYScroll();
lastScroll = docScroll;
new SmoothScroll(); // Khởi động hiệu ứng
});
}
src\PreloadExample.tsx
// PreloadScrollAnimation.tsx
import { useEffect } from "react";
import { getScrollPage } from "./scrollingEffect";
export default function PreloadScrollAnimation() {
useEffect(() => {
getScrollPage();
}, []);
return (
<div data-scroll>
<div className="content">
<div className="content__item">
<div className="content__item-imgwrap">
<div className="content__item-img" style={{ backgroundImage: 'url("/images/photo1.jpg")' }}></div>
</div>
<h2 className="content__item-title">Oh</h2>
<p className="content__item-description">Little trees and bushes grow however makes them happy.</p>
</div>
<div className="content__item">
<div className="content__item-imgwrap">
<div className="content__item-img" style={{ backgroundImage: 'url("/images/photo2.jpg")' }}></div>
</div>
<h2 className="content__item-title">Ri</h2>
<p className="content__item-description">We don't have to be committed. We are just playing here.</p>
</div>
</div>
</div>
);
}
src\App.tsx
import './App.css';
import PreloadExample from './PreloadExample';
function App() {
return (
<main>
<PreloadExample />
<PreloadExample />
<PreloadExample />
<PreloadExample />
</main>
);
}
export default App;
public\index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>React App</title>
</head>
<body class="loading">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
src\App.css
body {
margin: 0;
--aspect-ratio: 1/1.5;
--imgwidthmax: 500px;
--size-title: 10rem;
}
.content__item {
--imgwidth: calc(var(--imgwidthmax) * var(--aspect-ratio));
width: var(--imgwidth);
max-width: 100%;
position: relative;
will-change: transform;
}
/* = */
.content__item-imgwrap {
position: relative;
--imgwidth: 100%;
margin: 0 auto;
grid-area: 1 / 1 / 3 / 3;
overflow: hidden;
width: var(--imgwidth);
padding-bottom: calc(var(--imgwidth) / (var(--aspect-ratio)));
will-change: transform;
}
/* = */
.content__item-img {
--overflow: 40px;
height: calc(100% + (2 * var(--overflow)));
top: calc( -1 * var(--overflow));
width: 100%;
position: absolute;
background-size: cover;
background-position: 50% 0%;
will-change: transform;
opacity: 0.8;
}
/* = */
.content__item-title {
position: relative;
font-size: var(--size-title);
padding: 0 3vw;
margin: calc(var(--size-title) * -1/2) 0 0 0;
align-self: start;
line-height: 1;
font-family: var(--font-title);
font-weight: var(--font-weight-title);
color: var(--color-title);
will-change: transform;
mix-blend-mode: var(--blendmode-title);
}
package.json
{
"name": "reactjs",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/imagesloaded": "^4.1.7",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"imagesloaded": "^5.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Example 2: Áp dụng cho reactjs (áp dụng với image)
Chú ý: Sử dụng content__item-img thay đổi thành content__item_img vì trong tsx sử dụng biến
className={`content__item ${styles.content__item}`}
Thêm cái này
.content__item-img {
--overflow: 40px;
height: calc(100% + (2 * var(--overflow)));
top: calc(-1 * var(--overflow));
width: 100%;
position: absolute;
will-change: transform;
opacity: 0.8;
object-fit: cover;
}
src\PreloadExample.tsx
// PreloadScrollAnimation.tsx
import { useEffect } from "react";
import { getScrollPage } from "./scrollingEffect";
import styles from "./app.module.css";
export default function PreloadScrollAnimation() {
useEffect(() => {
getScrollPage();
}, []);
return (
<div data-scroll>
<div className={`content ${styles.content}`}>
<div className={`content__item ${styles.content__item}`}>
<div className={`content__item_imgwrap ${styles.content__item_imgwrap}`}>
<img className={`content__item_img ${styles.content__item_img}`} src="/images/photo1.jpg" alt="photo1" />
</div>
<h2 className={`content__item_title ${styles.content__item_title}`}>Oh</h2>
<p className="content__item-description">Little trees and bushes grow however makes them happy.</p>
</div>
<div className={`content__item ${styles.content__item}`}>
<div className={`content__item_imgwrap ${styles.content__item_imgwrap}`}>
<img className={`content__item_img ${styles.content__item_img}`} src="/images/photo2.jpg" alt="photo2" />
</div>
<h2 className={`content__item_title ${styles.content__item_title}`}>Ri</h2>
<p className="content__item-description">We don't have to be committed. We are just playing here.</p>
</div>
</div>
</div>
);
}
src\app.module.css
body {
margin: 0;
--aspect-ratio: 1/1.5;
--imgwidthmax: 500px;
--size-title: 10rem;
}
.content__item {
--imgwidth: calc(var(--imgwidthmax) * var(--aspect-ratio));
width: var(--imgwidth);
max-width: 100%;
position: relative;
will-change: transform;
}
/* = */
.content__item_imgwrap {
position: relative;
--imgwidth: 100%;
margin: 0 auto;
grid-area: 1 / 1 / 3 / 3;
overflow: hidden;
width: var(--imgwidth);
padding-bottom: calc(var(--imgwidth) / (var(--aspect-ratio)));
will-change: transform;
}
/* = */
.content__item_img {
--overflow: 40px;
height: calc(100% + (2 * var(--overflow)));
top: calc( -1 * var(--overflow));
width: 100%;
position: absolute;
will-change: transform;
opacity: 0.8;
object-fit: cover;
}
/* = */
.content__item_title {
position: relative;
font-size: var(--size-title);
padding: 0 3vw;
margin: calc(var(--size-title) * -1/2) 0 0 0;
align-self: start;
line-height: 1;
font-family: var(--font-title);
font-weight: var(--font-weight-title);
color: var(--color-title);
will-change: transform;
mix-blend-mode: var(--blendmode-title);
}
src\App.tsx
import PreloadExample from './PreloadExample';
function App() {
return (
<main>
<PreloadExample />
<PreloadExample />
<PreloadExample />
<PreloadExample />
</main>
);
}
export default App;
Example 3: Áp dụng cho nexttjs
Đã có bài viết sử dụng rồi.
https://app.gitbook.com/o/-LZtQgrzulP3oOTKQpDc/s/-MGMzApU_kEyI7nsYtZs/nghien-cuu-scrollingimageseffect
Last updated
Was this helpful?