# Nghiên cứu class scrollingEffect 👌

Đây có source html, js, css thuần

### Source

{% file src="<https://2086325979-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FOfwU52oyTCInVkN26DVZ%2Fuploads%2FlP7Ejm6yRHPOb2Kyve3R%2FscrollVertical.zip?alt=media&token=8293672b-f0ec-4789-9c6e-47443a7edee5>" %}

```tsx
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`

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)

<figure><img src="https://2086325979-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FOfwU52oyTCInVkN26DVZ%2Fuploads%2FPjeQcHjwSqj26dGafeMz%2Fimage.png?alt=media&#x26;token=01fa3eb1-4bd0-4853-8c59-164e04a3d028" alt=""><figcaption></figcaption></figure>

<figure><img src="https://2086325979-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FOfwU52oyTCInVkN26DVZ%2Fuploads%2FCv7BCqnqCpn34AFyFaE3%2Fimage.png?alt=media&#x26;token=d01e208f-5f5e-4382-9430-9ae01edfa05f" alt=""><figcaption></figcaption></figure>

Tham số **background: true**  &#x20;

<figure><img src="https://2086325979-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FOfwU52oyTCInVkN26DVZ%2Fuploads%2FsIg04WvhD5w8zNMa2haw%2Fimage.png?alt=media&#x26;token=efcc310b-024e-48b3-a2f1-07f95a0cc2e0" alt=""><figcaption><p><a href="https://imagesloaded.desandro.com/">https://imagesloaded.desandro.com</a></p></figcaption></figure>

<figure><img src="https://2086325979-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FOfwU52oyTCInVkN26DVZ%2Fuploads%2FDOxbUkAe40lwnU0cc40d%2Fimage.png?alt=media&#x26;token=4da840c1-d6a5-4499-af27-681f1e243888" alt=""><figcaption></figcaption></figure>

<figure><img src="https://2086325979-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FOfwU52oyTCInVkN26DVZ%2Fuploads%2FH7YcgMVHn5KsDzJ1Axvd%2Fimage.png?alt=media&#x26;token=a90c5ac8-7afe-47d5-8111-a31ecf4faa8d" alt=""><figcaption></figcaption></figure>

src\scrollingEffect.ts

```typescript
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

```typescript
// 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

```typescript
import './App.css';
import PreloadExample from './PreloadExample';
function App() {
  return (
    <main>
      <PreloadExample />
      <PreloadExample />
      <PreloadExample />
      <PreloadExample />
    </main>
  );
}
export default App;

```

public\index.html

```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

```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

```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&#x20;

```tsx
className={`content__item ${styles.content__item}`}
```

Thêm cái này

```css
.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

```typescript
// 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

```typescript
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

```typescript
import PreloadExample from './PreloadExample';
function App() {
  return (
    <main>
      <PreloadExample />
      <PreloadExample />
      <PreloadExample />
      <PreloadExample />
    </main>
  );
}
export default App;

```

### Example 3: Áp dụng cho nexttjs&#x20;

Đã có bài viết sử dụng rồi.

[https://app.gitbook.com/o/-LZtQgrzulP3oOTKQpDc/s/-MGMzApU\_kEyI7nsYtZs/nghien-cuu-scrollingimageseffect](https://javascriptuse.gitbook.io/advanced/type-script/broken-reference)
