We assume that by now you’ve all learn the great information about GSAP now changing into 100% free, for everybody. Due to Webflow’s help, the entire beforehand paid plugins in GSAP at the moment are accessible to everybody. That’s why right this moment, Osmo, Codrops and GSAP are teaming as much as deliver you 5 demos, out there each as a Webflow cloneable and CodePen. We hope these will present a enjoyable intro to some cool plugins and spark a couple of concepts!
What you’ll study:
- SplitText fundamentals: Break textual content into traces, phrases, or letters—with the brand new automated resizing and built-in masking choices!
- DrawSVG scribbles: Add a playful, randomized underline to hyperlinks (or something) on hover utilizing DrawSVG.
- Physics2D textual content smash: Mix SplitText + Physics2D so your headline shatters into letters that tumble off the highest of the viewport like a roof.
- Inertia dot grid: Create an interactive, glowing dot matrix that springs and flows along with your cursor for a dynamic background impact.
- MorphSVG toggle: Construct a seamless play/pause button that morphs one SVG into one other in a single tween.
Earlier than we dive in, let’s ensure you have the GSAP core included in your venture. I’ll let you understand the precise plugins you want per demo! You should utilize the official GSAP Set up Helper for those who want the right npm instructions or CDN hyperlinks. For those who’re following this as a Webflow person and also you wish to construct from scratch, Webflow has made it tremendous straightforward to combine GSAP into your venture. If you’d like, you’ll be able to learn extra right here. When utilizing this method, simply be sure so as to add your {custom} code someplace within the earlier than </physique>
part of the web page or venture settings.
Excellent, with that set, let’s begin constructing an interactive SplitText demo!
Interactive SplitText Demo

Earlier than we dive into code, a pair notes:
- Plugins wanted: GSAP core, SplitText, and (optionally) CustomEase.
- The CustomEase plugin isn’t required—be happy to swap in any ease or omit it solely—however we’ll use it right here to present our animation a particular really feel.
- Demo objective: We’re constructing an interactive demo right here, with buttons to set off totally different reveal types. For those who simply desire a one-off split-text reveal (e.g. on scroll or on load), you’ll be able to skip the buttons and wire your tween immediately into ScrollTrigger, Click on handlers, and so forth.
HTML and CSS Setup
<div class="text-demo-wrap">
<h1 data-split="heading" class="text-demo-h">
We’re utilizing GSAP’s SplitText to interrupt this content material into traces, phrases, and particular person characters. Experiment with staggered tweens, {custom} ease capabilities, and dynamic transforms to deliver your headlines to life.
</h1>
<div class="text-demo-buttons">
<button data-split="button" data-split-type="traces" class="text-demo-button"><span>Strains</span></button>
<button data-split="button" data-split-type="phrases" class="text-demo-button"><span>Phrases</span></button>
<button data-split="button" data-split-type="letters" class="text-demo-button"><span>Letters</span></button>
</div>
</div>
physique {
shade: #340824;
background-color: #d8e1ed;
}
.text-demo-wrap {
show: flex;
flex-direction: column;
align-items: heart;
hole: 4.5em;
max-width: 70em;
margin: 0 auto;
padding: 0 1.25em;
}
.text-demo-h {
font-size: 3.25vw;
font-weight: 500;
line-height: 1.15;
text-align: heart;
margin: 0;
}
.text-demo-buttons {
show: flex;
hole: 1.25em;
}
.text-demo-button {
padding: .625em 1.25em;
font-size: 1.625em;
border-radius: 100em;
background: #fff;
transition: background .15s, shade .15s;
}
.text-demo-button:hover {
background: #340824;
shade: #fff;
}
1. Register plugins (and non-compulsory ease)
Begin by registering SplitText (and CustomEase, for those who’d like a bespoke curve).
gsap.registerPlugin(SplitText, CustomEase);
// Elective: a {custom} ease
CustomEase.create("osmo-ease", "0.625, 0.05, 0, 1");
2. Cut up your heading into traces, phrases & letters
This single name does the heavy lifting: it splits your <h1> into three ranges of granularity, wraps every line in a masked container, and retains the whole lot in sync on resize.
const heading = doc.querySelector('[data-split="heading"]');
SplitText.create(heading, {
kind: "traces, phrases, chars", // cut up by traces, phrases & characters
masks: "traces", // non-compulsory: wraps every line in an overflow-clip <div> for a masks impact later
linesClass: "line",
wordsClass: "phrase",
charsClass: "letter"
});
masks: "traces"
wraps every line in its personal container so you are able to do masked reveals with out further markup.
3. Hook up the buttons
Since this can be a showcase, we’ve added three buttons. One every for “Strains”, “Phrases” and “Letters”—to let customers set off every type on demand. In an actual venture you would possibly hearth these tweens on scroll, on web page load, or when one other interplay happens.
To maintain our code a bit cleaner, we outline a config object that maps every cut up kind to its supreme length and stagger. As a result of traces, phrases, and letters have vastly totally different counts, matching your timing to the variety of parts ensures every animation feels tight and responsive.
For those who used the identical stagger for letters as you do for traces, animating dozens (or a whole lot) of chars would take without end. Tailoring the stagger to the aspect rely retains the reveal snappy.
// 1. Outline per-type timing
const config = {
traces: { length: 0.8, stagger: 0.08 },
phrases: { length: 0.6, stagger: 0.06 },
letters: { length: 0.4, stagger: 0.008 }
};
Subsequent, our animate(kind) operate:
operate animate(kind) {
// 1) Clear up any working tween so clicks “restart” cleanly
if (currentTween) {
currentTween.kill();
gsap.set(currentTargets, { yPercent: 0 });
}
// 2) Pull the appropriate timing from our config
const { length, stagger } = config[type];
// 3) Match the button’s data-split-type to the CSS class
// Our SplitText name used linesClass="line", wordsClass="phrase", charsClass="letter"
const selector = kind === "traces" ? ".line"
: kind === "phrases" ? ".phrase"
: ".letter";
// 4) Question the right parts and animate
currentTargets = heading.querySelectorAll(selector);
currentTween = gsap.fromTo(
currentTargets,
{ yPercent: 110 },
{ yPercent: 0, length, stagger, ease: "osmo-ease" }
);
}
Discover how kind
(the button’s data-split-type) immediately aligns with our config keys and the category names we set on every slice. This tidy mapping means you’ll be able to add new sorts (or swap class names) with out rewriting your logic—simply replace config (and your SplitText choices) and the operate auto-adapts.
Lastly, tie all of it along with occasion listeners:
const buttons = doc.querySelectorAll('[data-split="button"]');
buttons.forEach(btn =>
btn.addEventListener("click on", () =>
animate(btn.dataset.splitType)
)
);
4. Placing all of it collectively
Let’s put all of our JS collectively in a single neat operate, and name it as quickly as our fonts are loaded. This fashion we keep away from splitting textual content whereas a fallback font is seen, and with that, we keep away from any surprising line breaks.
// JavaScript (guarantee GSAP, SplitText & CustomEase are loaded)
gsap.registerPlugin(SplitText, CustomEase);
CustomEase.create("osmo-ease", "0.625, 0.05, 0, 1");
operate initSplitTextDemo() {
const heading = doc.querySelector('[data-split="heading"]');
SplitText.create(heading, {
kind: "traces, phrases, chars",
masks: "traces",
linesClass: "line",
wordsClass: "phrase",
charsClass: "letter"
});
const config = {
traces: { length: 0.8, stagger: 0.08 },
phrases: { length: 0.6, stagger: 0.06 },
letters: { length: 0.4, stagger: 0.008 }
};
let currentTween, currentTargets;
operate animate(kind) {
if (currentTween) {
currentTween.kill();
gsap.set(currentTargets, { yPercent: 0 });
}
const { length, stagger } = config[type];
const selector = kind === "traces" ? ".line"
: kind === "phrases" ? ".phrase"
: ".letter";
currentTargets = heading.querySelectorAll(selector);
currentTween = gsap.fromTo(
currentTargets,
{ yPercent: 110 },
{ yPercent: 0, length, stagger, ease: "osmo-ease" }
);
}
doc.querySelectorAll('[data-split="button"]').forEach(btn =>
btn.addEventListener("click on", () =>
animate(btn.dataset.splitType)
)
);
}
doc.fonts.prepared.then(initSplitTextDemo);
5. Assets & hyperlinks
Give it a spin your self! Discover this demo on CodePen and seize the Webflow cloneable beneath. For a deep dive into each out there choice, try the official SplitText docs, and head over to the CustomEase documentation to discover ways to craft your individual easing curves.
→ CodePen
We’ll proceed subsequent with the Physics2D Textual content Smash demo—combining SplitText with one other GSAP plugin for a completely totally different impact.
Physics2D Textual content Smash Demo

For those who weren’t conscious already, with the latest Webflow × GSAP bulletins, SplitText obtained a significant overhaul—filled with highly effective new choices, accessibility enhancements, and a dramatically smaller bundle measurement. Try the SplitText docs for all the small print.
In contrast to our earlier demo (which was extra of an interactive playground with buttons), this impact is rather a lot nearer to a real-world utility; as you scroll, every heading “breaks” into characters and falls off of your viewport prefer it’s hit a roof—because of ScrollTrigger and Physics2DPlugin.
Earlier than we dive into code, a pair notes:
- Plugins wanted: GSAP core, SplitText, ScrollTrigger, and Physics2DPlugin.
- Belongings used: We’re utilizing some squiggly, enjoyable, 3D objects from a free pack on wannathis.one. Positively try their stuff, they’ve extra enjoyable issues!
- Demo objective: We’re combining SplitText + Physics2D on scroll so your headings shatter into characters and “fall” off the highest of the viewport, as in the event that they hit a ‘roof’.
HTML & CSS Setup
<div class="drop-wrapper">
<div class="drop-section">
<h1 data-drop-text="" class="drop-heading">
That is only a
<span data-drop-img="" class="drop-heading-img is--first"><img loading="lazy" src="https://cdn.prod.website-files.com/681a615bf5a0f1ba3cb1ca38/681a62d0bb34b74d3514ecab_shape-squigle-1.png" alt=""></span>
random quote
<span data-drop-img="" class="drop-heading-img is--second"><img loading="lazy" src="https://cdn.prod.website-files.com/681a615bf5a0f1ba3cb1ca38/681a62d0bb34b74d3514ecad_shape-squigle-2.png" alt=""></span>
we used
</h1>
</div>
<div class="drop-section">
<h1 data-drop-text="" class="drop-heading">
See how our window acts like
<span data-drop-img="" class="drop-heading-img is--third"><img loading="lazy" src="https://cdn.prod.website-files.com/681a615bf5a0f1ba3cb1ca38/681a62d0bb34b74d3514ecaf_shape-squigle-3.png" alt=""></span>
a roof?
</h1>
</div>
<div class="drop-section">
<h1 data-drop-text="" class="drop-heading">A lot enjoyable!</h1>
</div>
</div>
physique {
shade: #efeeec;
background-color: #340824;
}
.drop-wrapper {
width: 100%;
min-height: 350vh;
}
.drop-section {
show: flex;
justify-content: heart;
align-items: heart;
min-height: 100vh;
place: relative;
}
.drop-heading {
max-width: 40rem;
margin: 0;
font-size: 4rem;
font-weight: 500;
line-height: 1;
text-align: heart;
}
.drop-heading-img {
show: inline-block;
place: relative;
width: 1.4em;
z-index: 2;
}
.drop-heading-img.is--first {
rework: rotate(-20deg) translate(.15em, -.2em);
}
.drop-heading-img.is--second {
rework: translate(-.15em) rotate(10deg);
}
.drop-heading-img.is--third {
rework: translate(-.05em, .1em) rotate(50deg);
margin: 0 .1em;
}
1. Register plugins
Begin by registering all of our needed plugins
gsap.registerPlugin(ScrollTrigger, SplitText, Physics2DPlugin);
2. SplitText setup
We’re utilizing aria: true
right here to routinely add an aria-label on the wrapper and conceal cut up spans from display readers. For the reason that newest replace, aria: true
is the default, so that you don’t essentially have so as to add it right here—however we’re highlighting it for the article.
We cut up the textual content as quickly because the code runs, in order that we will connect a callback to the brand new onSplit
operate, however extra on that in step 3.
new SplitText("[data-drop-text]", {
kind: "traces, chars",
autoSplit: true, // re-split if the aspect resizes and it is cut up by traces
aria: true, // default now, however price highlighting!
linesClass: "line",
});
With the latest SplitText replace, there’s additionally a brand new choice referred to as autoSplit—which takes care of resize occasions, and re-splitting your textual content.
An necessary caveat for the autoSplit
choice; it is best to at all times create your animations within the (additionally new!) onSplit()
callback in order that in case your textual content re-splits (when the container resizes or a font hundreds in), the ensuing animations have an effect on the freshly-created line/phrase/character parts as a substitute of those from the earlier cut up. For those who’re planning on utilizing a non-responsive font-size or simply wish to study extra about this (superior) new function that takes care of responsive line splitting, try the documentation right here.
3. Set off on scroll
In our onSplit
callback, we loop over every line within the heading, inside a context. This context, which we return on the finish, makes positive GSAP can clear up this animation every time the textual content re-splits.
In our loop, we create a ScrollTrigger for every line, and we set as soon as: true
, so our animation solely fires as soon as. In step 4 we’ll add our animation!
It’s price taking part in round with the begin
values to essentially nail the second the place your textual content visually ‘touches’ the highest of the window. For our font, measurement, and line-height combo, an offset of 10px labored nice.
new SplitText("[data-drop-text]", {
kind: "traces, chars",
autoSplit: true,
aria: true,
linesClass: "line",
onSplit(self) {
// use a context to gather up all of the animations
let ctx = gsap.context(() => {
self.traces.forEach((line) => { // loop across the traces
gsap.timeline({
scrollTrigger: {
as soon as: true, // solely hearth as soon as
set off: line, // use the road as a set off
begin: "prime top-=10" // modify the set off level to your liking
}
})
});
});
return ctx; // return our animations so GSAP can clear them up when onSplit fires
}
});
4. Drop the letters with Physics2D
Now, let’s add 2 tweens to our timeline. The primary one, utilizing the Physics2D plugin, sends every baby aspect of the road, flying straight down with randomized velocity, angle, and gravity. A second tween makes positive the weather are pale out in the direction of the top.
new SplitText("[data-drop-text]", {
kind: "traces, chars",
autoSplit: true,
aria: true,
linesClass: "line",
onSplit(self) {
// use a context to gather up all of the animations
let ctx = gsap.context(() => {
self.traces.forEach((line) => { // loop across the traces
gsap.timeline({
scrollTrigger: {
as soon as: true, // solely hearth as soon as
set off: line, // use the road as a set off
begin: "prime top-=10" // modify the set off level to your liking
}
})
.to(line.kids, { // goal the youngsters
length: "random(1.5, 3)", // Use randomized values for a extra dynamic animation
physics2D: {
velocity: "random(500, 1000)",
angle: 90,
gravity: 3000
},
rotation: "random(-90, 90)",
ease: "none"
})
.to(line.kids,{ // Begin fading them out
autoAlpha: 0,
length: 0.2
}, "-=.2");
});
});
return ctx; // return our animations so GSAP can clear them up when onSplit fires
}
});
Tip: use gsap.utils.random()
! Giving every char and picture a barely totally different velocity and spin creates a joyful, and extra pure feeling to all of it.
5. Placing all of it collectively
gsap.registerPlugin(ScrollTrigger, SplitText, Physics2DPlugin);
operate initDroppingText() {
new SplitText("[data-drop-text]", {
kind: "traces, chars",
autoSplit: true,
aria: true,
linesClass: "line",
onSplit(self) {
// use a context to gather up all of the animations
let ctx = gsap.context(() => {
self.traces.forEach((line) => {
gsap
.timeline({
scrollTrigger: {
as soon as: true,
set off: line,
begin: "prime top-=10"
}
})
.to(line.kids, { // goal the youngsters
length: "random(1.5, 3)", // Use randomized values for a extra dynamic animation
physics2D: {
velocity: "random(500, 1000)",
angle: 90,
gravity: 3000
},
rotation: "random(-90, 90)",
ease: "none"
})
.to(
line.kids,
{
autoAlpha: 0,
length: 0.2
},
"-=.2"
);
});
});
return ctx; // return our animations so GSAP can clear them up when onSplit fires
}
});
}
doc.addEventListener("DOMContentLoaded", initDroppingText);
6. Assets & hyperlinks
→ CodePen
Subsequent up: an interactive Inertia Dot Grid that springs and flows along with your cursor!
Glowing Interactive Dot Grid

InertiaPlugin (previously ThrowPropsPlugin) means that you can easily glide any property to a cease, honoring an preliminary velocity in addition to making use of non-compulsory restrictions on the top worth. It brings real-world momentum to your parts, letting them transfer with an preliminary velocity and easily gradual beneath configurable resistance. You merely specify a beginning velocity and resistance worth, and the plugin handles the physics.
On this demo, we’re utilizing a quick-to-prototype grid of <div> dots that glow as your cursor approaches, spring away on fast mouse actions, and ripple outward on clicks. Whereas a Canvas or WebGL method would scale extra effectively for 1000’s of particles and ship greater frame-rates, our div-based resolution retains the code easy and accessible—excellent for spotlighting InertiaPlugin’s capabilities.
Earlier than we dive in:
- Plugins wanted: GSAP core and InertiaPlugin.
- Demo objective: Construct a responsive grid of dots that glow with proximity and spring away on quick mouse strikes or clicks—showcasing how the InertiaPlugin can add playful, physics-based reactions to a format.
HTML & CSS Setup
<div class="dots-wrap">
<div data-dots-container-init class="dots-container">
<div class="dot"></div>
</div>
</div>
<part class="section-resource">
<a href="https://osmo.provide/" goal="_blank" class="osmo-icon__link">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 160 160" fill="none" class="osmo-icon-svg">
<path d="M94.8284 53.8578C92.3086 56.3776 88 54.593 88 51.0294V0H72V59.9999C72 66.6273 66.6274 71.9999 60 71.9999H0V87.9999H51.0294C54.5931 87.9999 56.3777 92.3085 53.8579 94.8283L18.3431 130.343L29.6569 141.657L65.1717 106.142C67.684 103.63 71.9745 105.396 72 108.939V160L88.0001 160L88 99.9999C88 93.3725 93.3726 87.9999 100 87.9999H160V71.9999H108.939C105.407 71.9745 103.64 67.7091 106.12 65.1938L106.142 65.1716L141.657 29.6568L130.343 18.3432L94.8284 53.8578Z" fill="currentColor"></path>
</svg>
</a>
</part>
physique {
overscroll-behavior: none;
background-color: #08342a;
shade: #efeeec;
}
.dots-container {
place: absolute;
inset: 4em;
show: flex;
flex-flow: wrap;
hole: 2em;
justify-content: heart;
align-items: heart;
pointer-events: none;
}
.dot {
place: relative;
width: 1em;
peak: 1em;
border-radius: 50%;
background-color: #245e51;
transform-origin: heart;
will-change: rework, background-color;
rework: translate(0);
place-self: heart;
}
.section-resource {
shade: #efeeec;
justify-content: heart;
align-items: heart;
show: flex;
place: absolute;
inset: 0;
}
.osmo-icon-svg {
width: 10em;
}
.osmo-icon__link {
shade: currentColor;
text-decoration: none;
}
1. Register plugins
gsap.registerPlugin(InertiaPlugin);
2. Construct your grid & non-compulsory heart gap
First, wrap the whole lot in an initGlowingInteractiveDotsGrid()
operate and declare your tweakable parameters—colours, glow distance, velocity thresholds, shockwave settings, max pointer velocity, and whether or not to carve out a middle gap for a brand. We additionally arrange two arrays, dots and dotCenters, to trace the weather and their positions.
operate initGlowingInteractiveDotsGrid() {
const container = doc.querySelector('[data-dots-container-init]');
const colours = { base: "#245E51", energetic: "#A8FF51" };
const threshold = 200;
const speedThreshold = 100;
const shockRadius = 325;
const shockPower = 5;
const maxSpeed = 5000;
const centerHole = true;
let dots = [];
let dotCenters = [];
// buildGrid(), mousemove & click on handlers outlined subsequent…
}
With these in place, buildGrid()
figures out what number of columns and rows match based mostly in your container’s em
sizing, then optionally carves out a superbly centered block of 4 or 5 columns/rows (relying on whether or not the grid dimensions are even or odd) if centerHole
is true. That gap offers area in your brand; set centerHole = false
to fill each cell.
Inside buildGrid()
, we:
- Filter any present dots and reset our arrays.
- Learn the container’s fontSize to get dotPx (in px) and derive gapPx.
- Calculate what number of columns and rows match, plus the overall cells.
- Compute a centered “gap” of 4 or 5 columns/rows if centerHole is true, so you’ll be able to place a brand or focal aspect.
operate buildGrid() {
container.innerHTML = "";
dots = [];
dotCenters = [];
const type = getComputedStyle(container);
const dotPx = parseFloat(type.fontSize);
const gapPx = dotPx * 2;
const contW = container.clientWidth;
const contH = container.clientHeight;
const cols = Math.ground((contW + gapPx) / (dotPx + gapPx));
const rows = Math.ground((contH + gapPx) / (dotPx + gapPx));
const whole = cols * rows;
const holeCols = centerHole ? (cols % 2 === 0 ? 4 : 5) : 0;
const holeRows = centerHole ? (rows % 2 === 0 ? 4 : 5) : 0;
const startCol = (cols - holeCols) / 2;
const startRow = (rows - holeRows) / 2;
// …subsequent: loop by means of every cell to create dots…
}
Now loop over each cell index. Inside that loop, we disguise any dot within the gap area and initialize the seen ones with GSAP’s set()
. Every dot is appended to the container and pushed into our dots array for monitoring.
For every dot:
- If it falls within the gap area, we disguise it.
- In any other case, we place it at { x: 0, y: 0 } with the bottom shade and mark it as not but sprung.
- Append it to the container and monitor it in dots.
// ... add this to the buildGrid() operate
for (let i = 0; i < whole; i++) {
const row = Math.ground(i / cols);
const col = i % cols;
const isHole =
centerHole &&
row >= startRow &&
row < startRow + holeRows &&
col >= startCol &&
col < startCol + holeCols;
const d = doc.createElement("div");
d.classList.add("dot");
if (isHole) {
d.type.visibility = "hidden";
d._isHole = true;
} else {
gsap.set(d, { x: 0, y: 0, backgroundColor: colours.base });
d._inertiaApplied = false;
}
container.appendChild(d);
dots.push(d);
}
// ... extra code added beneath
Lastly, as soon as the DOM is up to date, measure every seen dot’s heart coordinate—together with any scroll offset—so we will calculate distances later. Wrapping in requestAnimationFrame ensures the format is settled.
// ... add this to the buildGrid() operate
requestAnimationFrame(() => {
dotCenters = dots
.filter(d => !d._isHole)
.map(d => {
const r = d.getBoundingClientRect();
return {
el: d,
x: r.left + window.scrollX + r.width / 2,
y: r.prime + window.scrollY + r.peak / 2
};
});
});
// that is the top of the buildGrid() operate
By now, the whole buildGrid() operate will appear to be the next:
operate buildGrid() {
container.innerHTML = "";
dots = [];
dotCenters = [];
const type = getComputedStyle(container);
const dotPx = parseFloat(type.fontSize);
const gapPx = dotPx * 2;
const contW = container.clientWidth;
const contH = container.clientHeight;
const cols = Math.ground((contW + gapPx) / (dotPx + gapPx));
const rows = Math.ground((contH + gapPx) / (dotPx + gapPx));
const whole = cols * rows;
const holeCols = centerHole ? (cols % 2 === 0 ? 4 : 5) : 0;
const holeRows = centerHole ? (rows % 2 === 0 ? 4 : 5) : 0;
const startCol = (cols - holeCols) / 2;
const startRow = (rows - holeRows) / 2;
for (let i = 0; i < whole; i++) {
const row = Math.ground(i / cols);
const col = i % cols;
const isHole = centerHole &&
row >= startRow && row < startRow + holeRows &&
col >= startCol && col < startCol + holeCols;
const d = doc.createElement("div");
d.classList.add("dot");
if (isHole) {
d.type.visibility = "hidden";
d._isHole = true;
} else {
gsap.set(d, { x: 0, y: 0, backgroundColor: colours.base });
d._inertiaApplied = false;
}
container.appendChild(d);
dots.push(d);
}
requestAnimationFrame(() => {
dotCenters = dots
.filter(d => !d._isHole)
.map(d => {
const r = d.getBoundingClientRect();
return {
el: d,
x: r.left + window.scrollX + r.width / 2,
y: r.prime + window.scrollY + r.peak / 2
};
});
});
}
On the finish of initGlowingInteractiveDotsGrid(), we connect a resize listener and invoke buildGrid() as soon as to kick issues off:
window.addEventListener("resize", buildGrid);
buildGrid();
3. Deal with mouse transfer interactions
Because the person strikes their cursor, we calculate its velocity by evaluating the present e.pageX/e.pageY to the final recorded place over time (dt
). We clamp that velocity to maxSpeed
to keep away from runaway values. Then, on the subsequent animation body, we loop by means of every dot’s heart:
- Compute its distance to the cursor and derive
t = Math.max(0, 1 - dist / threshold)
. - Interpolate its shade from
colours.base
tocolours.energetic
. - If
velocity > speedThreshold
and the dot is inside threshold, mark it_inertiaApplied
and hearth an inertia tween to push it away earlier than it springs again.
All this nonetheless goes inside our initGlowingInteractiveDotsGrid()
operate:
let lastTime = 0
let lastX = 0
let lastY = 0
window.addEventListener("mousemove", e => {
const now = efficiency.now()
const dt = now - lastTime || 16
let dx = e.pageX - lastX
let dy = e.pageY - lastY
let vx = (dx / dt) * 1000
let vy = (dy / dt) * 1000
let velocity = Math.hypot(vx, vy)
if (velocity > maxSpeed) {
const scale = maxSpeed / velocity
vx = vx * scale
vy = vy * scale
velocity = maxSpeed
}
lastTime = now
lastX = e.pageX
lastY = e.pageY
requestAnimationFrame(() => {
dotCenters.forEach(({ el, x, y }) => {
const dist = Math.hypot(x - e.pageX, y - e.pageY)
const t = Math.max(0, 1 - dist / threshold)
const col = gsap.utils.interpolate(colours.base, colours.energetic, t)
gsap.set(el, { backgroundColor: col })
if (velocity > speedThreshold && dist < threshold && !el._inertiaApplied) {
el._inertiaApplied = true
const pushX = (x - e.pageX) + vx * 0.005
const pushY = (y - e.pageY) + vy * 0.005
gsap.to(el, {
inertia: { x: pushX, y: pushY, resistance: 750 },
onComplete() {
gsap.to(el, {
x: 0,
y: 0,
length: 1.5,
ease: "elastic.out(1, 0.75)"
})
el._inertiaApplied = false
}
})
}
})
})
})
4. Deal with click on ‘shockwave’ impact
On every click on
, we ship a radial ‘shockwave’ by means of the grid. We reuse the identical inertia + elastic return logic, however scale the push by a distance-based falloff
in order that dots nearer to the clicking transfer additional, then all spring again in unison.
window.addEventListener("click on", e => {
dotCenters.forEach(({ el, x, y }) => {
const dist = Math.hypot(x - e.pageX, y - e.pageY)
if (dist < shockRadius && !el._inertiaApplied) {
el._inertiaApplied = true
const falloff = Math.max(0, 1 - dist / shockRadius)
const pushX = (x - e.pageX) * shockPower * falloff
const pushY = (y - e.pageY) * shockPower * falloff
gsap.to(el, {
inertia: { x: pushX, y: pushY, resistance: 750 },
onComplete() {
gsap.to(el, {
x: 0,
y: 0,
length: 1.5,
ease: "elastic.out(1, 0.75)"
})
el._inertiaApplied = false
}
})
}
})
})
5. Placing all of it collectively
By now, all of our items stay inside one initGlowingInteractiveDotsGrid() operate. Right here’s an abbreviated view of your remaining JS setup:
gsap.registerPlugin(InertiaPlugin);
operate initGlowingInteractiveDotsGrid() {
// buildGrid(): creates and positions dots
// window.addEventListener("mousemove", …): glow & spring logic
// window.addEventListener("click on", …): shockwave logic
}
doc.addEventListener("DOMContentLoaded", initGlowingInteractiveDotsGrid);
6. Assets & hyperlinks
→ CodePen
Subsequent up: DrawSVG Scribbles Demo — let’s draw some playful, randomized underlines on hover!
DrawSVG Scribbles Demo

GSAP’s DrawSVGPlugin animates the stroke of an SVG path by tweening its stroke-dasharray and stroke-dashoffset, making a ‘drawing’ impact. You may management begin/finish percentages, length, easing, and even stagger a number of paths. On this demo, we’ll connect a randomized scribble underline to every hyperlink on hover—excellent for including a playful contact to your navigation or call-to-actions.
- Plugins wanted: GSAP core and DrawSVGPlugin
- Demo objective: On hover, inject a random SVG scribbles beneath your hyperlink textual content and animate it from 0% to 100% draw, then erase it on hover-out.
HTML & CSS Setup
<part class="section-resource">
<a data-draw-line href="#" class="text-draw w-inline-block">
<p class="text-draw__p">Branding</p>
<div data-draw-line-box class="text-draw__box"></div>
</a>
<a data-draw-line href="#" class="text-draw w-inline-block">
<p class="text-draw__p">Design</p>
<div data-draw-line-box class="text-draw__box"></div>
</a>
<a data-draw-line href="#" class="text-draw w-inline-block">
<p class="text-draw__p">Improvement</p>
<div data-draw-line-box class="text-draw__box"></div>
</a>
</part>
physique {
background-color: #fefaee;
}
.section-resource {
show: flex;
justify-content: heart;
align-items: heart;
min-height: 100vh;
font-size: 1.5vw;
}
.text-draw {
shade: #340824;
cursor: pointer;
margin: 0 1em;
font-size: 2em;
text-decoration: none;
}
.text-draw__p {
margin-bottom: 0;
font-size: 1.5em;
font-weight: 500;
line-height: 1.1;
}
.text-draw__box {
place: relative;
width: 100%;
peak: .625em;
shade: #e55050;
}
.text-draw__box-svg {
place: absolute;
prime: 0;
left: 0;
width: 100%;
peak: 100%;
overflow: seen !necessary;
}
1. Register the plugin
gsap.registerPlugin(DrawSVGPlugin);
2. Put together your SVG variants
We outline an array of tangible SVG scribbles. Every string is a standalone <svg>
with its <path>
. After we inject it, we run decorateSVG()
to make sure it scales to its container and makes use of currentColor
for theming.
We’ve drawn these scribbles ourselves in figma utilizing the pencil. We suggest drawing (and thus creating the trail coordinates) within the order of which you wish to draw them.
const svgVariants = [
`<svg width="310" height="40" viewBox="0 0 310 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 20.9999C26.7762 16.2245 49.5532 11.5572 71.7979 14.6666C84.9553 16.5057 97.0392 21.8432 109.987 24.3888C116.413 25.6523 123.012 25.5143 129.042 22.6388C135.981 19.3303 142.586 15.1422 150.092 13.3333C156.799 11.7168 161.702 14.6225 167.887 16.8333C181.562 21.7212 194.975 22.6234 209.252 21.3888C224.678 20.0548 239.912 17.991 255.42 18.3055C272.027 18.6422 288.409 18.867 305 17.9999" stroke="currentColor" stroke-width="10" stroke-linecap="round"/></svg>`,
`<svg width="310" height="40" viewBox="0 0 310 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 24.2592C26.233 20.2879 47.7083 16.9968 69.135 13.8421C98.0469 9.5853 128.407 4.02322 158.059 5.14674C172.583 5.69708 187.686 8.66104 201.598 11.9696C207.232 13.3093 215.437 14.9471 220.137 18.3619C224.401 21.4596 220.737 25.6575 217.184 27.6168C208.309 32.5097 197.199 34.281 186.698 34.8486C183.159 35.0399 147.197 36.2657 155.105 26.5837C158.11 22.9053 162.993 20.6229 167.764 18.7924C178.386 14.7164 190.115 12.1115 201.624 10.3984C218.367 7.90626 235.528 7.06127 252.521 7.49276C258.455 7.64343 264.389 7.92791 270.295 8.41825C280.321 9.25056 296 10.8932 305 13.0242" stroke="#E55050" stroke-width="10" stroke-linecap="round"/></svg>`,
`<svg width="310" height="40" viewBox="0 0 310 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 29.5014C9.61174 24.4515 12.9521 17.9873 20.9532 17.5292C23.7742 17.3676 27.0987 17.7897 29.6575 19.0014C33.2644 20.7093 35.6481 24.0004 39.4178 25.5014C48.3911 29.0744 55.7503 25.7731 63.3048 21.0292C67.9902 18.0869 73.7668 16.1366 79.3721 17.8903C85.1682 19.7036 88.2173 26.2464 94.4121 27.2514C102.584 28.5771 107.023 25.5064 113.276 20.6125C119.927 15.4067 128.83 12.3333 137.249 15.0014C141.418 16.3225 143.116 18.7528 146.581 21.0014C149.621 22.9736 152.78 23.6197 156.284 24.2514C165.142 25.8479 172.315 17.5185 179.144 13.5014C184.459 10.3746 191.785 8.74853 195.868 14.5292C199.252 19.3205 205.597 22.9057 211.621 22.5014C215.553 22.2374 220.183 17.8356 222.979 15.5569C225.4 13.5845 227.457 11.1105 230.742 10.5292C232.718 10.1794 234.784 12.9691 236.164 14.0014C238.543 15.7801 240.717 18.4775 243.356 19.8903C249.488 23.1729 255.706 21.2551 261.079 18.0014C266.571 14.6754 270.439 11.5202 277.146 13.6125C280.725 14.7289 283.221 17.209 286.393 19.0014C292.321 22.3517 298.255 22.5014 305 22.5014" stroke="#E55050" stroke-width="10" stroke-linecap="round"/></svg>`,
`<svg width="310" height="40" viewBox="0 0 310 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.0039 32.6826C32.2307 32.8412 47.4552 32.8277 62.676 32.8118C67.3044 32.807 96.546 33.0555 104.728 32.0775C113.615 31.0152 104.516 28.3028 102.022 27.2826C89.9573 22.3465 77.3751 19.0254 65.0451 15.0552C57.8987 12.7542 37.2813 8.49399 44.2314 6.10216C50.9667 3.78422 64.2873 5.81914 70.4249 5.96641C105.866 6.81677 141.306 7.58809 176.75 8.59886C217.874 9.77162 258.906 11.0553 300 14.4892" stroke="#E55050" stroke-width="10" stroke-linecap="round"/></svg>`,
`<svg width="310" height="40" viewBox="0 0 310 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.99805 20.9998C65.6267 17.4649 126.268 13.845 187.208 12.8887C226.483 12.2723 265.751 13.2796 304.998 13.9998" stroke="currentColor" stroke-width="10" stroke-linecap="round"/></svg>`,
`<svg width="310" height="40" viewBox="0 0 310 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 29.8857C52.3147 26.9322 99.4329 21.6611 146.503 17.1765C151.753 16.6763 157.115 15.9505 162.415 15.6551C163.28 15.6069 165.074 15.4123 164.383 16.4275C161.704 20.3627 157.134 23.7551 153.95 27.4983C153.209 28.3702 148.194 33.4751 150.669 34.6605C153.638 36.0819 163.621 32.6063 165.039 32.2029C178.55 28.3608 191.49 23.5968 204.869 19.5404C231.903 11.3436 259.347 5.83254 288.793 5.12258C294.094 4.99476 299.722 4.82265 305 5.45025" stroke="#E55050" stroke-width="10" stroke-linecap="round"/></svg>`
];
operate decorateSVG(svgEl) {
svgEl.setAttribute('class', 'text-draw__box-svg');
svgEl.setAttribute('preserveAspectRatio', 'none');
svgEl.querySelectorAll('path').forEach(path => {
path.setAttribute('stroke', 'currentColor');
});
}
3. Arrange hover animations
For every hyperlink, we pay attention for mouseenter
and mouseleave
. On hover-in, we:
- Forestall restarting if the earlier draw-in tween continues to be energetic.
- Kill any ongoing draw-out tween.
- Decide the subsequent SVG variant (biking by means of the array).
- Inject it into the field, beautify it, set its preliminary drawSVG to “0%”, then tween to “100%” in 0.5s with an ease of
power2.inOut
.
On hover-out, we tween drawSVG from “100% 100%” to erase it, then clear the SVG when full.
let nextIndex = null;
doc.querySelectorAll('[data-draw-line]').forEach(container => {
const field = container.querySelector('[data-draw-line-box]');
if (!field) return;
let enterTween = null;
let leaveTween = null;
container.addEventListener('mouseenter', () => {
if (enterTween && enterTween.isActive()) return;
if (leaveTween && leaveTween.isActive()) leaveTween.kill();
if (nextIndex === null) {
nextIndex = Math.ground(Math.random() * svgVariants.size);
}
field.innerHTML = svgVariants[nextIndex];
const svg = field.querySelector('svg');
if (svg) {
decorateSVG(svg);
const path = svg.querySelector('path');
gsap.set(path, { drawSVG: '0%' });
enterTween = gsap.to(path, {
length: 0.5,
drawSVG: '100%',
ease: 'power2.inOut',
onComplete: () => { enterTween = null; }
});
}
nextIndex = (nextIndex + 1) % svgVariants.size;
});
container.addEventListener('mouseleave', () => {
const path = field.querySelector('path');
if (!path) return;
const playOut = () => {
if (leaveTween && leaveTween.isActive()) return;
leaveTween = gsap.to(path, {
length: 0.5,
drawSVG: '100% 100%',
ease: 'power2.inOut',
onComplete: () => {
leaveTween = null;
field.innerHTML = '';
}
});
};
if (enterTween && enterTween.isActive()) {
enterTween.eventCallback('onComplete', playOut);
} else {
playOut();
}
});
});
4. Initialize on web page load
Wrap the above setup in your initDrawRandomUnderline() operate and name it as soon as the DOM is prepared:
operate initDrawRandomUnderline() {
// svgVariants, decorateSVG, and all occasion listeners…
}
doc.addEventListener('DOMContentLoaded', initDrawRandomUnderline);
5. Assets & hyperlinks
→ CodePen
And now on to the ultimate demo: MorphSVG Toggle Demo—see the way to morph one icon into one other in a single tween!
MorphSVG Toggle Demo

MorphSVGPlugin allows you to fluidly morph one SVG form into one other—even once they have totally different numbers of factors—by intelligently mapping anchor factors. You may select the morphing algorithm (measurement, place or complexity), management easing, length, and even add rotation to make the transition really feel further clean. On this demo, we’re toggling between a play ► and pause ❚❚ icon on button click on, then flipping again. Excellent for video gamers, music apps, or any interactive management.
We extremely suggest diving into the docs for this plugin, as there are an entire bunch of choices and potentialities.
- Plugins wanted: GSAP core and MorphSVGPlugin
- Demo objective: Construct a play/pause button that seamlessly morphs its SVG path on every click on.
HTML & CSS Setup
<button data-play-pause="toggle" class="play-pause-button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 25" class="play-pause-icon">
<path
data-play-pause="path"
d="M3.5 5L3.50049 3.9468C3.50049 3.177 4.33382 2.69588 5.00049 3.08078L20.0005 11.741C20.6672 12.1259 20.6672 13.0882 20.0005 13.4731L17.2388 15.1412L17.0055 15.2759M3.50049 8L3.50049 21.2673C3.50049 22.0371 4.33382 22.5182 5.00049 22.1333L14.1192 16.9423L14.4074 16.7759"
stroke="currentColor"
stroke-width="2"
stroke-miterlimit="16"
fill="none"
/>
</svg>
</button>
physique {
background-color: #0e100f;
shade: #fffce1;
show: flex;
flex-direction: column;
align-items: heart;
justify-content: heart;
peak: 100vh;
margin: 0;
}
.play-pause-button {
background: clear;
border: none;
width: 10rem;
peak: 10rem;
show: flex;
align-items: heart;
justify-content: heart;
shade: currentColor;
cursor: pointer;
}
.play-pause-icon {
width: 100%;
peak: 100%;
}
1. Register the plugin
gsap.registerPlugin(MorphSVGPlugin);
2. Outline paths & toggle logic
We retailer two path definitions: playPath
and pausePath
, then seize our button and the <path>
aspect inside it. A easy isPlaying
boolean tracks state. On every click on, we name gsap.to()
on the SVG path, passing morphSVG choices:
- kind: “rotational” to easily rotate factors into place
- map: “complexity” to match by variety of anchors for velocity
- form set to the other icon’s path
Lastly, we flip isPlaying
so the subsequent click on morphs again.
operate initMorphingPlayPauseToggle() {
const playPath =
"M3.5 5L3.50049 3.9468C3.50049 3.177 4.33382 2.69588 5.00049 3.08078L20.0005 11.741C20.6672 12.1259 20.6672 13.0882 20.0005 13.4731L17.2388 15.1412L17.0055 15.2759M3.50049 8L3.50049 21.2673C3.50049 22.0371 4.33382 22.5182 5.00049 22.1333L14.1192 16.9423L14.4074 16.7759";
const pausePath =
"M15.5004 4.05859V5.0638V5.58691V8.58691V15.5869V19.5869V21.2549M8.5 3.96094V10.3721V17V19L8.5 21";
const buttonToggle = doc.querySelector('[data-play-pause="toggle"]');
const iconPath = buttonToggle.querySelector('[data-play-pause="path"]');
let isPlaying = false;
buttonToggle.addEventListener("click on", () => {
gsap.to(iconPath, {
length: 0.5,
ease: "power4.inOut",
morphSVG: {
kind: "rotational",
map: "complexity",
form: isPlaying ? playPath : pausePath
}
});
isPlaying = !isPlaying;
});
}
doc.addEventListener("DOMContentLoaded", initMorphingPlayPauseToggle);
4. Assets & hyperlinks
- MorphSVGPlugin docs
- Bonus: We additionally added a confetti impact on click on utilizing the Physics2DPlugin for the beneath Webflow and CodePen sources!
→ CodePen
And that wraps up our MorphSVG Toggle!
Closing ideas
Thanks for making it this far down the web page! We all know it’s a reasonably lengthy learn, so we hope there’s some inspiring stuff in right here for you. Each Dennis and I are tremendous stoked with all of the GSAP Plugins being free now, and might’t wait to create extra sources with them.
As a word, we’re absolutely conscious that every one the HTML and markup within the article is reasonably concise, and positively lower than customary with all finest practices for accessibility. To make these sources production-ready, positively search for steering on the requirements at w3.org! Consider the above ones as your launch-pad. Able to tweak and make your individual.
Have a stunning remainder of your day, or night time, wherever you might be. Pleased animating!
Entry a rising library of sources

Constructed by two award-winning inventive builders Dennis Snellenberg and Ilja van Eck, our vault offers you entry to the strategies, parts, code, and instruments behind our initiatives. All neatly packed in a custom-built dashboard. Construct, tweak, and make them your individual—for Webflow and non-Webflow customers.
Turn into a member right this moment to unlock our rising set of parts and be part of a group of greater than 850 inventive builders worldwide!