Explode Text On Scroll (Matter.js physics)
Goal
Build a scroll-driven text effect: a full-screen pinned paragraph starts invisible (text is the same color as the background), its words progressively light up in red as you scroll, then the highlighted keywords turn white — and at 60% of the pinned scroll they shatter into individual characters that fall and bounce on the floor with real Matter.js physics. Scrolling back up reverses everything and re-assembles the text.
Tech
Vanilla HTML/CSS/JS with ES module imports (Vite-style npm imports). Use:
gsap(npm) plus the GSAP pluginScrollTrigger(register it withgsap.registerPlugin(ScrollTrigger)).lenis(npm) for smooth scroll.split-type(npm, theSplitTypeclass) to split the paragraph into words.matter-js(npm) for the physics simulation (useEngine,Runner,World,Bodies,Body,Events).
No images and no canvas rendering — Matter.js runs headless and drives DOM spans via CSS transforms.
Wire Lenis into GSAP the standard way:
lenis.on("scroll", ScrollTrigger.update)gsap.ticker.add((time) => lenis.raf(time * 1000))gsap.ticker.lagSmoothing(0)
Run everything inside DOMContentLoaded.
Layout / HTML
Three stacked full-viewport sections:
section.intro— contains:- a
navwith two<p>pill labels:MenuandLet's talk. - an
<h1>readingMotionprompts, centered. section.sticky— contains a single long<p>(this is the section that gets pinned). Use this exact paragraph text (it must contain the highlight keywords listed below):
> Motionprompts is a YouTube channel where you learn to code modern web designs using HTML, CSS, JavaScript, React and NextJS. Focused on creating aesthetic interfaces, web animations, and immersive experiences, Motionprompts draws inspiration from the captivating websites showcased on platforms like Awwwards and Godly. With a keen eye for design, Motionprompts crafts elements and interfaces that exhibit a similar taste, often employing the power of GSAP and ScrollTrigger. In a nutshell, Motionprompts is all about coding elite web designs. In 2017, inspired by the standout designs of Awwwards, Motionprompts emerged. Beyond merely admiring these designs, there was a vision: to recreate them. The channel identified a gap — although these designs were exceptional, there wasn't a clear method for many to replicate them. It was this void that Motionprompts sought to fill. From its very inception, the platform has dedicated itself to converting design marvels into structured, comprehensible guides for coders of all proficiency levels. Recently, MotionpromptsPRO was introduced, a subscription-based service tailored to the needs of passionate web designers. As a PRO member, you gain exclusive access to source code for each tutorial and monthly website templates. These resources are carefully curated to support and inspire your creativity, helping you take your web design skills to the next level. MotionpromptsPRO opens up a realm of opportunities for professional growth and empowers you to bring your ideas to life with ease.
section.outro— contains an<h1>readingOne subscription, endless web design., centered.
Styling
- Google Font Space Mono (weights 400 and 700, plus italics), applied to
body.* { margin:0; padding:0; box-sizing:border-box; },body { overflow-x: hidden; }. - Every
section:position: relative; width: 100vw; height: 100vh; padding: 2em; overflow: hidden;. .introand.outro: flex, center both axes.- Palette:
.intro: background#EBEAE4, text#0F0F0F..outro: background#EB4330(red-orange), text#0F0F0F..sticky: background#0F0F0F.- Key trick:
.sticky p { color: #0F0F0F; }— identical to the section background, so the paragraph is invisible until GSAP animates the word colors. nav:position: absolute; top: 0; left: 0; width: 100vw; padding: 2em; display: flex; justify-content: space-between; align-items: center;. Eachnav pis a pill:text-transform: uppercase; font-size: 12px; padding: 2px 8px; border-radius: 20px; background: #0F0F0F; color: #EBEAE4;..char(the physics character spans created in JS):display: inline-block; position: absolute; pointer-events: none; opacity: 0;.
GSAP effect (be precise)
1. Word splitting and highlight words
- Split
.sticky pwithnew SplitType(".sticky p", { types: "words" })and collecttext.words. - Define the highlight keyword list:
["YouTube", "aesthetic", "immersive", "exceptional", "inspiration", "recreate", "void", "passionate", "PRO", "creativity", "life"]
wordsToHighlight= every split word whosetextContentincludes any keyword (substring match, soMotionpromptsPROandcreativity,match too). Setword.style.opacity = 1on each of them.
2. Matter.js setup
Engine.create({ gravity: { x: 0, y: 0 } })— gravity starts OFF. Create aRunnerandRunner.run(runner, engine)immediately.- Add one static floor:
Bodies.rectangle(window.innerWidth / 2, window.innerHeight + 5, window.innerWidth, 20, { isStatic: true })— a bar sitting just below the bottom edge of the viewport.
3. Per-character physics bodies
For each word in wordsToHighlight, split its textContent into characters. For each character:
- Create a
<span class="char">with the character as text,position: absolute, and append it to.sticky(NOT inside the word). - Position it to exactly overlay its character within the word:
charWidth = word.offsetWidth / chars.length,x = wordRect.left − stickyRect.left + charIndex * charWidth,y = wordRect.top − stickyRect.top(rects measured withgetBoundingClientRect()at build time). Setleft = x px,top = y px, and copy the word's computedcolor. - Create a Matter rectangle body centered at
(x + charWidth/2, y + charSpan.offsetHeight/2)with sizecharWidth × charSpan.offsetHeightand options{ restitution: 0.75, friction: 0.5, frictionAir: 0.0175, isStatic: true }. Add it to the world. - Keep a record
{ body, element, initialX: x, initialY: y }in acharBodiesarray (and the spans in acharElementsarray).
The spans stay at opacity: 0 (CSS) until the explosion.
4. Pinned, scrubbed master timeline
Create the master timeline with this ScrollTrigger:
trigger: ".sticky"
start: "top top"
end: "+=" + (window.innerHeight * 4) + "px" // 4 viewport-heights of pinned scroll
pin: true
scrub: true
Master timeline content (total duration 4 timeline-seconds, scrubbed across the pin distance):
- Phase 1 — words turn red (timeline position 0): build a nested timeline. Shuffle a copy of ALL words (Fisher–Yates). For each shuffled word add
phase1.to(word, { color: "#EB4330", duration: 0.1, ease: "power2.inOut" }, Math.random() * 0.9)— i.e. every word flips to red in a 0.1s tween placed at a random time within the first ~1 second, producing a sparkling random-order colorization. Add withtl.add(phase1, 0). - Phase 2 — highlight words turn white (timeline position 1): build a second nested timeline. Shuffle a copy of
wordsToHighlight(Fisher–Yates). For each addphase2.to(word, { color: "#FFFFFF", duration: 0.1, ease: "power2.inOut" }, Math.random() * 0.9). Add withtl.add(phase2, 1). - Padding: append
tl.to({}, { duration: 2 })— two seconds of empty timeline so the color phases finish by 50% progress and the last stretch of scroll is reserved for the explosion moment.
5. Explosion trigger (in ScrollTrigger onUpdate)
Track lastProgress to derive scroll direction, and a physicsEnabled boolean flag.
- When
self.progress >= 0.6, scrolling DOWN, and physics not yet enabled: - Set
physicsEnabled = trueandengine.world.gravity.y = 1. - Hide every highlight word (
word.style.opacity = 0) and show every char span (element.style.opacity = 1; element.style.color = "#FFFFFF"). - For each char body:
Body.setStatic(body, false),Body.setAngularVelocity(body, (Math.random() − 0.5) * 0.25),Body.setVelocity(body, { x: (Math.random() − 0.5) * 5, y: −Math.random() * 5 })— a small random upward/sideways kick with spin, then gravity takes over and they fall and bounce on the floor. - Simultaneously fade OUT all non-highlight words with
gsap.to(nonHighlightWords, { opacity: 0, duration: 0.5, ease: "power2.out" })so only the flying letters remain visible. - When
self.progress < 0.6, scrolling UP, and physics is enabled: reset everything: physicsEnabled = false,gravity.y = 0.- For each char body:
Body.setStatic(body, true),Body.setPositionback to(initialX + element.offsetWidth/2, initialY + element.offsetHeight/2),Body.setAngle(body, 0), zero out velocity and angular velocity; clear the span'stransformand set itsopacity = 0. - Fade ALL words back in:
gsap.to(word, { opacity: 1, duration: 0.5, ease: "power2.in" })for each word.
6. Physics → DOM sync
Subscribe to Matter's Events.on(engine, "afterUpdate", ...). On every tick, if physicsEnabled, for each { body, element, initialX, initialY } compute the offset from the initial body center:
deltaX = body.position.x − (initialX + element.offsetWidth / 2)
deltaY = body.position.y − (initialY + element.offsetHeight / 2)
element.style.transform = `translate(${deltaX}px, ${deltaY}px) rotate(${body.angle}rad)`
This makes each character span follow its physics body (position + rotation) while falling and bouncing.
Assets / images
None. The whole piece is typography and solid color blocks.
Behavior notes
- Desktop-oriented; the layout/measurement happens once on load (no resize handling needed).
- The explosion is a one-shot state toggled by scroll direction: it fires once past 60% scrolling down, and fully resets (letters snap back, words fade in) once you scroll back above 60% going up. Scrubbing the color phases remains fully reversible at all times.
- The floor is invisible; characters come to rest along the bottom edge of the pinned viewport.
- Expect the characters to render in white (
#FFFFFF) while flying, on the near-black#0F0F0Fsticky background, with the red#EB4330outro section revealed after the pin ends.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--paper,--ink,--ultramarine,--ink-soft,--paper-soft,--rule-ink,--rule-paper. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the
lenis.on("scroll", ScrollTrigger.update)wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component builds a Matter Engine, a Runner, a Lenis instance and a SplitType instance the moment it mounts. Run that construction twice without tearing the first copy down and you get two physics worlds simulating into the same character spans, two afterUpdate listeners writing transform onto the same .char nodes, two Lenis instances pulling on the same wheel event, and a paragraph split into words twice over — so the second split's "words" are actually spans wrapping already-split spans, and the highlight-word substring match starts missing text it should have caught. The visible symptom is characters falling twice as fast with jittery rotation, or the word color sweep never reaching a stable end state, and none of it will reproduce in a production build, because React only does the double mount in development. The teardown this script already documents — the block under "Desmontaje" that stops the runner, clears the engine, removes the char spans and reverts the split — is not an optional nicety layered on top of the effect; it *is* the effect, half of it, and React needs it back verbatim as the function the effect returns.
(1) The entry point. mount currently boots itself by checking document.readyState and subscribing to DOMContentLoaded only if the document is still loading. That guard exists so this file works when dropped into a plain HTML page at any point in the load sequence; inside React it is dead weight, because useEffect already runs after the DOM this effect measures — .sticky, its <p>, wordRect, stickyRect — has committed. Drop the readyState check, the DOMContentLoaded listener, and the window.MP editor-registration branch entirely. What's left — everything from resolving gravityY/restitution/friction/frictionAir/burstSpeed/spin off the config, through building the floor body and wiring the pinned timeline — becomes the body of a useEffect with an empty dependency array. The config values that used to arrive through DEFAULTS or the editor's window.MP.register become the effect's own local constants, or props if the host app needs to drive them.
(2) Element lookups. document.querySelector(".sticky") and the sticky.querySelector("p") beneath it assume this component owns the whole document. Give the pinned section a root ref, resolve sticky and paragraph from that ref instead of from document, and take stickyRect from the same scoped node. This is not a style nit here specifically: during the StrictMode remount, two .sticky sections briefly coexist in the DOM, and an unscoped document.querySelector will bind to whichever one the browser hands back first — not necessarily the copy this particular effect run is about to build char spans and physics bodies against. Bind everything through the ref and each mount only ever touches its own subtree.
(3) Cleanup — GSAP and ScrollTrigger. Wrap the pinned master timeline, the two nested color-sweep timelines (phase1, phase2) and the ScrollTrigger they carry in a gsap.context scoped to the root ref, and revert that context in the effect's cleanup:
useEffect(() => {
const ctx = gsap.context(() => {
/* build the floor body, the char spans + bodies, and the pinned
ScrollTrigger timeline exactly as described above */
}, rootRef);
return () => ctx.revert();
}, []);
This one call kills the master timeline, phase1, phase2 and the pin's spacer together, so a StrictMode remount doesn't leave a second pin fighting the first one over the same trigger element. One thing the context does not catch here: the tweens this effect creates *after* the synchronous setup returns. resetAnimation() and the explosion branch both call gsap.to(word, …) from inside the ScrollTrigger's onUpdate, which fires later, on scroll — outside the window during which gsap.context auto-tracks what it creates. Left alone those tweens survive ctx.revert(). Wrap each of those calls in ctx.add(() => { … }) so the context adopts them too, or keep the script's own belt-and-suspenders move and call gsap.killTweensOf(words) in the cleanup regardless — the same explicit safety net the original destroy() already relies on, for exactly this reason.
gsap.ticker.add(raf) is the other thing the context doesn't cover — a ticker subscription is neither a tween nor a timeline. Keep the raf function reference this effect creates and call gsap.ticker.remove(raf) directly in the cleanup, in the same place the script's own destroy() puts it. Skipping it leaves Lenis's raf pump running against a destroyed instance after every remount, because gsap.ticker is a global shared across the whole page, not something scoped to this component.
Cleanup — Lenis. This component owns its Lenis instance outright (it drives the pin's scrub and nothing else on the page needs smooth scroll), so create it inside the effect and call lenis.destroy() in the cleanup, after removing the lenis.on("scroll", ScrollTrigger.update) subscription with the matching lenis.off. If this component ends up as one section of a larger app that already runs Lenis elsewhere, don't create a second instance here — there can only be one Lenis on the page — and instead subscribe the existing instance's scroll event to ScrollTrigger.update once, at the app shell.
Cleanup — SplitType. new SplitType(paragraph, { types: "words" }) rewrites the paragraph's DOM into one span per word, and every highlight word gets a further layer of manually created .char spans appended as siblings inside .sticky. Revert in the opposite order things were built: remove the .char spans first (they hold live references to the words' getBoundingClientRect() measurements, taken once at mount and never revisited), *then* call text.revert() to hand the paragraph back its original, unsplit markup. Do this inside the same gsap.context cleanup, after ctx.revert() has already killed the tweens that still target those word spans — reverting the split while a tween is still targeting one of the split nodes is what corrupts the second mount's highlight-word matching.
Cleanup — matter-js. The physics side of this component is a running simulation, not a one-shot calculation, and it is the most expensive thing on this list to leave alive: Runner.run(runner, engine) keeps stepping the world every frame for as long as the runner exists, whether or not anyone is looking at the page, and Events.on(engine, "afterUpdate", onAfterUpdate) keeps writing transform onto this mount's specific charBodies closures forever. In the cleanup, in this order: Events.off(engine, "afterUpdate", onAfterUpdate) first, so nothing writes to the DOM after the elements it targets may already be gone; Runner.stop(runner); then World.clear(engine.world, false) and Engine.clear(engine). Do this before reverting the split, or the runner can fire one more afterUpdate tick against character spans the split revert is about to remove.
Put together, the effect's return function is the fixed sequence the script's own destroy() already lays out — kill the ScrollTrigger and timelines, remove the ticker subscription, tear down Lenis, stop and clear the Matter engine, remove the char spans, revert the split, then clear any inline styles left on sticky/paragraph — and that sequence is what makes the component safe to mount, unmount, and mount again without the falling letters getting heavier, faster, or more numerous each time.