嗨咿,我是 illumi。昨天在gsap 進入的動畫中有沒有看到一堆字掉下來呢?
我們在前幾天一起做了GSAP掉落效果,現在這個是他的進階版!為了不讓文字重疊在一起看不到,我加上了帶有物理效果的Matter.js,可以加上物理碰撞和堆疊!
一起來做做看吧!
請先安裝必要套件(在你的 React 專案根目錄):
npm install gsap matter-js
# TypeScript 專案建議也裝 type 定義(開發時)
npm install -D @types/matter-js
這裡你需要三個主要東西:
useRef
, useEffect
, useState
, useCallback
)gsap
與 ScrollTrigger
(用來偵測畫面進入/離開)matter-js
(物理引擎,處理掉落、碰撞、彈性)import { useEffect, useRef, useState, useCallback } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import * as Matter from "matter-js";
gsap.registerPlugin(ScrollTrigger);
為什麼要這樣載入?
gsap.registerPlugin(ScrollTrigger)
:讓 GSAP 知道要使用 ScrollTrigger(否則 ScrollTrigger.create
會無效)。Matter
是整套 2D 物理系統,我們會用它創建 engine、body、world。我們把掉落的文字放在一個固定(fixed)容器上,並用 position: absolute
放每個文字元素,畫面由 state 控制每個文字的 x、y、angle。
簡化的 JSX 架構長這樣(你可以把它放在任何頁面的適合位置):
<div
ref={containerRef}
className="fixed inset-0 pointer-events-none z-40 transition-opacity duration-300"
style={{ opacity: containerOpacity }}
>
{textElements.map((element) => (
<div
key={element.id}
className="absolute rounded-full bg-schema-primary text-schema-on-primary text-xs font-medium px-4 py-2 shadow-lg"
style={{
left: element.x - 60,
top: element.y - 20,
transform: `rotate(${element.angle}rad)`,
minWidth: "120px",
height: "40px",
transformOrigin: "center center",
}}
>
{element.text}
</div>
))}
</div>
重點說明
fixed inset-0
:讓容器覆蓋整個視窗空間。這樣物理世界和 DOM 座標是以視窗左上為 (0,0)。pointer-events-none
:避免遮蔽頁面互動(文字純裝飾不阻塞點擊)。containerOpacity
:用來做淡入/淡出(配合 ScrollTrigger 的 onEnter/onLeave)。left/top
由物理引擎回傳的 body.position
決定(我們會把 body 的位置寫回 state)。我們不想文字永遠都掉;通常做法是在「當使用者滾動到某區域」時,讓文字掉下來一次;離開後移除,回來再重置,這樣不會一直跑而浪費資源。
下面是關鍵函數(用在 useEffect
中):
ScrollTrigger.create({
trigger: document.body,
start: "1% top",
end: "18% top",
onEnter: () => { /* show container, add bodies to world, start physics */ },
onLeave: () => { /* hide container, remove bodies, stop physics */ },
onEnterBack: () => { /* 同 onEnter,回滾回來時再播 */ },
onLeaveBack: () => { /* 同 onLeave */ },
});
屬性簡單解釋
trigger
: 要監聽哪個元素(也可以是某個 section)。此例用 document.body
(代表整頁)做範例,實務上可以改成你想偵測進入的區塊。start: "1% top"
:觸發起點,當頁面往下 1% 時就會觸發 onEnter
。end: "18% top"
:結束點(當滾動到 18% 時會觸發離開)。onEnter / onLeave / onEnterBack / onLeaveBack
:常用的四個回呼,負責開始/停止效果。onEnter
裡,我們會把 bodies 加入 engine.world,並啟動物理更新循環;在 onLeave
則移除並停止循環。Matter 的流程大概是:
Matter.Engine.create()
:建立物理引擎。Bodies.rectangle(x,y,width,height, options)
建成物理體(body)。requestAnimationFrame
)呼叫 Matter.Engine.update(engine, delta)
,並將 body 的 position
與 angle
拿回來更新 DOM(用 state 或直接 transform DOM)。關鍵參數說明(在建立 body 時)
restitution
:彈性,數字越大越會彈起(0 ~ 1)。
friction
:摩擦力(碰撞時的阻力)。
frictionAir
:空氣阻力(速度衰減)。
density
:密度,影響慣性與碰撞力。
這些參數影響掉落的感覺:彈性高、摩擦低會彈跳多、移動激烈。
在你的範例裡,我們會把每個 text 的初始 x 隨機,y 放在畫面上方(-100、-150 …),然後在 onEnter
把它們 Matter.World.add(world, bodies)
,這會使文字從天而降。
物理引擎跑在 JS 世界,DOM 要靠 React 更新畫面。範例用 requestAnimationFrame
做循環(approx 60FPS):
const updatePhysics = () => {
if (engineRef.current && isPhysicsRunning) {
Matter.Engine.update(engineRef.current, 16.666); // 約 60fps
setTextElements(prev => prev.map((el, idx) => {
const body = bodiesRef.current[idx];
return body ? {
...el,
x: body.position.x,
y: body.position.y,
angle: clampedAngle
} : el;
}));
}
if (isPhysicsRunning) {
animationFrameRef.current = requestAnimationFrame(updatePhysics)
}
}
要注意的效能點
setState
會觸發 React re-render;大量物件會造成效能壓力。el.style.transform
),但那樣程式會更複雜。先用 state 版本簡單可理解,再優化。為了節省效能,我們不會在頁面任何時候都跑 physics;ScrollTrigger 的回呼會控制 isPhysicsRunning
:
onEnter
/ onEnterBack
→ 設 isPhysicsRunning = true
,把 bodies 加回 world,並呼叫 updatePhysics()
開始 RAF 循環。onLeave
/ onLeaveBack
→ 設 isPhysicsRunning = false
,取消 RAF 並從 world 中移出 bodies。你的原始程式用 handleVisibilityChange
和 setContainerOpacity(1|0)
來做這件事,這樣畫面也會配合淡入淡出。
React 組件卸載時必須:
cancelAnimationFrame
(若還有循環)
Matter.Engine.clear(engineRef.current)
或直接移除 engine
ScrollTrigger 的 ctx.revert()
(若使用 gsap.context
)
否則會造成記憶體洩漏或 background 工作持續跑。
left: element.x - 60
的補正值(這裡 60 是半寬補正,依你實際 width 調整)。rotate(${angle}rad)
。engine.world.gravity.y
,或改 restitution/friction
。window.matchMedia("(prefers-reduced-motion: reduce)")
跳過物理動畫。// TextDrop.tsx
import { useEffect, useRef, useState, useCallback } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import * as Matter from "matter-js";
gsap.registerPlugin(ScrollTrigger);
const TEXTS = [
"1",
"2",
"3",
"4",
];
const TextDrop = ({ className }: { className?: string }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [containerOpacity, setContainerOpacity] = useState(0);
const engineRef = useRef<Matter.Engine | null>(null);
const bodiesRef = useRef<Matter.Body[]>([]);
const animationFrameRef = useRef<number | undefined>(undefined);
const [isPhysicsRunning, setIsPhysicsRunning] = useState(false);
const [textElements, setTextElements] = useState<
Array<{ text: string; x: number; y: number; angle: number; id: string }>
>([]);
// 用來啟動/停止物理更新循環 (避免循環依賴)
const handleVisibilityChange = useCallback((isVisible: boolean) => {
setIsPhysicsRunning((prev) => {
if (isVisible && !prev) {
return true;
} else if (!isVisible && prev) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
return false;
}
return prev;
});
}, []);
// 初始化 engine、bodies 與 ScrollTrigger
useEffect(() => {
if (!containerRef.current) return;
// 1) 初始化 Matter 引擎
const engine = Matter.Engine.create();
engine.world.gravity.y = 0.8; // 重力大小,可調
engineRef.current = engine;
// 2) 建立靜態地板與牆壁(避免掉出邊界)
const ground = Matter.Bodies.rectangle(
window.innerWidth / 2,
window.innerHeight - 30,
window.innerWidth,
120,
{ isStatic: true }
);
const leftWall = Matter.Bodies.rectangle(
-25,
window.innerHeight / 2,
50,
window.innerHeight,
{ isStatic: true }
);
const rightWall = Matter.Bodies.rectangle(
window.innerWidth + 25,
window.innerHeight / 2,
50,
window.innerHeight,
{ isStatic: true }
);
Matter.World.add(engine.world, [ground, leftWall, rightWall]);
// 3) 創建文字物理體(初始放在視窗上方,會掉下)
const bodies: Matter.Body[] = [];
const initialElements = TEXTS.map((text, idx) => {
const width = Math.max(120, text.length * 12 + 32);
const height = 40;
const x = Math.random() * (window.innerWidth - width - 100) + width / 2;
const y = -100 - idx * 50;
const body = Matter.Bodies.rectangle(x, y, width, height, {
restitution: 0.6,
friction: 0.3,
frictionAir: 0.01,
density: 0.001,
angle: (Math.random() * 60 - 30) * (Math.PI / 180),
});
bodies.push(body);
return {
text,
x,
y,
angle: body.angle,
id: `text-${idx}`,
};
});
bodiesRef.current = bodies;
setTextElements(initialElements);
// 4) 用 GSAP ScrollTrigger 控制何時加入 / 移除物理
const ctx = gsap.context(() => {
ScrollTrigger.create({
trigger: document.body,
start: "1% top",
end: "18% top",
onEnter: () => {
setContainerOpacity(1);
handleVisibilityChange(true);
// 重置物理體位置到天空(每次都重新放置)
bodies.forEach((body, idx) => {
const x = Math.random() * (window.innerWidth - 200) + 100;
const y = -100 - idx * 50;
Matter.Body.setPosition(body, { x, y });
Matter.Body.setVelocity(body, { x: 0, y: 0 });
Matter.Body.setAngle(
body,
(Math.random() * 60 - 30) * (Math.PI / 180)
);
Matter.Body.setAngularVelocity(body, 0);
});
Matter.World.add(engine.world, bodies);
},
onLeave: () => {
setContainerOpacity(0);
handleVisibilityChange(false);
Matter.World.remove(engine.world, bodies);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
},
onEnterBack: () => {
setContainerOpacity(1);
handleVisibilityChange(true);
bodies.forEach((body, idx) => {
const x = Math.random() * (window.innerWidth - 200) + 100;
const y = -100 - idx * 50;
Matter.Body.setPosition(body, { x, y });
Matter.Body.setVelocity(body, { x: 0, y: 0 });
Matter.Body.setAngle(
body,
(Math.random() * 60 - 30) * (Math.PI / 180)
);
Matter.Body.setAngularVelocity(body, 0);
});
Matter.World.add(engine.world, bodies);
},
onLeaveBack: () => {
setContainerOpacity(0);
handleVisibilityChange(false);
Matter.World.remove(engine.world, bodies);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
},
});
}, containerRef);
// 清理(unmount)
return () => {
ctx.revert();
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (engineRef.current) {
Matter.Engine.clear(engineRef.current);
engineRef.current = null;
}
};
}, [handleVisibilityChange]);
// 物理更新函數(每幀把 body 的位置寫回 state)
const updatePhysics = useCallback(() => {
if (engineRef.current && isPhysicsRunning) {
Matter.Engine.update(engineRef.current, 16.666); // 約 60fps
setTextElements((prevElements) =>
prevElements.map((element, idx) => {
const body = bodiesRef.current[idx];
if (body) {
// 限制旋轉角度在 ±30度(避免過度旋轉)
const clampedAngle = Math.max(
-Math.PI / 6,
Math.min(Math.PI / 6, body.angle)
);
if (body.angle !== clampedAngle) {
Matter.Body.setAngle(body, clampedAngle);
}
return {
...element,
x: body.position.x,
y: body.position.y,
angle: clampedAngle,
};
}
return element;
})
);
}
if (isPhysicsRunning) {
animationFrameRef.current = requestAnimationFrame(updatePhysics);
}
}, [isPhysicsRunning]);
// 監聽 isPhysicsRunning,啟動或停止更新循環
useEffect(() => {
if (isPhysicsRunning && engineRef.current) {
updatePhysics();
} else if (!isPhysicsRunning && animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
}, [isPhysicsRunning, updatePhysics]);
return (
<div
ref={containerRef}
className={`fixed inset-0 pointer-events-none z-40 transition-opacity duration-300 ${className}`}
style={{ opacity: containerOpacity }}
>
{textElements.map((element) => (
<div
key={element.id}
className="absolute rounded-full bg-schema-primary text-schema-on-primary flex items-center justify-center text-xs md:text-p font-medium shadow-lg px-4 py-2"
style={{
left: element.x - 60,
top: element.y - 20,
userSelect: "none",
transform: `rotate(${element.angle}rad)`,
textAlign: "center",
lineHeight: "1.2",
minWidth: "120px",
height: "40px",
transformOrigin: "center center",
}}
>
{element.text}
</div>
))}
</div>
);
};
export default TextDrop;
呼,終於,今天就到這啦~我們明天見~