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 additional markup.
3. Hook up the buttons
Since it is a showcase, we’ve added three buttons. One every for “Strains”, “Phrases” and “Letters”—to let customers set off every model on demand. In an actual challenge 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 ultimate period and stagger. As a result of traces, phrases, and letters have vastly completely different counts, matching your timing to the variety of parts ensures every animation feels tight and responsive.
Should you used the identical stagger for letters as you do for traces, animating dozens (or a whole lot) of chars would take eternally. Tailoring the stagger to the ingredient rely retains the reveal snappy.
// 1. Outline per-type timing
const config = {
traces: { period: 0.8, stagger: 0.08 },
phrases: { period: 0.6, stagger: 0.06 },
letters: { period: 0.4, stagger: 0.008 }
};
Subsequent, our animate(kind) perform:
perform 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 { period, 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 proper parts and animate
currentTargets = heading.querySelectorAll(selector);
currentTween = gsap.fromTo(
currentTargets,
{ yPercent: 110 },
{ yPercent: 0, period, stagger, ease: "osmo-ease" }
);
}
Discover how kind
(the button’s data-split-type) straight aligns with our config keys and the category names we set on every slice. This tidy mapping means you possibly can add new sorts (or swap class names) with out rewriting your logic—simply replace config (and your SplitText choices) and the perform 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 perform, 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");
perform 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: { period: 0.8, stagger: 0.08 },
phrases: { period: 0.6, stagger: 0.06 },
letters: { period: 0.4, stagger: 0.008 }
};
let currentTween, currentTargets;
perform animate(kind) {
if (currentTween) {
currentTween.kill();
gsap.set(currentTargets, { yPercent: 0 });
}
const { period, stagger } = config[type];
const selector = kind === "traces" ? ".line"
: kind === "phrases" ? ".phrase"
: ".letter";
currentTargets = heading.querySelectorAll(selector);
currentTween = gsap.fromTo(
currentTargets,
{ yPercent: 110 },
{ yPercent: 0, period, 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 personal easing curves.
→ CodePen
We’ll proceed subsequent with the Physics2D Textual content Smash demo—combining SplitText with one other GSAP plugin for a very completely different impact.
Physics2D Textual content Smash Demo

Should you weren’t conscious already, with the latest Webflow × GSAP bulletins, SplitText obtained a serious overhaul—filled with highly effective new choices, accessibility enhancements, and a dramatically smaller bundle dimension. Take a look at 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 quite a bit 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.
- Property 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
physique {
coloration: #efeeec;
background-color: #340824;
}
.drop-wrapper {
width: 100%;
min-height: 350vh;
}
.drop-section {
show: flex;
justify-content: middle;
align-items: middle;
min-height: 100vh;
place: relative;
}
.drop-heading {
max-width: 40rem;
margin: 0;
font-size: 4rem;
font-weight: 500;
line-height: 1;
text-align: middle;
}
.drop-heading-img {
show: inline-block;
place: relative;
width: 1.4em;
z-index: 2;
}
.drop-heading-img.is--first {
remodel: rotate(-20deg) translate(.15em, -.2em);
}
.drop-heading-img.is--second {
remodel: translate(-.15em) rotate(10deg);
}
.drop-heading-img.is--third {
remodel: translate(-.05em, .1em) rotate(50deg);
margin: 0 .1em;
}
1. Register plugins
Begin by registering all of our vital 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 screen 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 are able to connect a callback to the brand new onSplit
perform, however extra on that in step 3.
new SplitText("[data-drop-text]", {
kind: "traces, chars",
autoSplit: true, // re-split if the ingredient 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 known as autoSplit—which takes care of resize occasions, and re-splitting your textual content.
An essential 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 masses in), the ensuing animations have an effect on the freshly-created line/phrase/character parts as an alternative of those from the earlier cut up. Should you’re planning on utilizing a non-responsive font-size or simply need 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, within a context. This context, which we return on the finish, makes certain 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 actually nail the second the place your textual content visually ‘touches’ the highest of the window. For our font, dimension, 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: "high 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 youngster ingredient of the road, flying straight down with randomized velocity, angle, and gravity. A second tween makes certain the weather are pale out in 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: "high top-=10" // modify the set off level to your liking
}
})
.to(line.youngsters, { // goal the youngsters
period: "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.youngsters,{ // Begin fading them out
autoAlpha: 0,
period: 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 completely different pace and spin creates a joyful, and extra pure feeling to all of it.
5. Placing all of it collectively
gsap.registerPlugin(ScrollTrigger, SplitText, Physics2DPlugin);
perform 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: "high top-=10"
}
})
.to(line.youngsters, { // goal the youngsters
period: "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.youngsters,
{
autoAlpha: 0,
period: 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 together 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 elective restrictions on the top worth. It brings real-world momentum to your parts, letting them transfer with an preliminary velocity and easily sluggish 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
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
physique {
overscroll-behavior: none;
background-color: #08342a;
coloration: #efeeec;
}
.dots-container {
place: absolute;
inset: 4em;
show: flex;
flex-flow: wrap;
hole: 2em;
justify-content: middle;
align-items: middle;
pointer-events: none;
}
.dot {
place: relative;
width: 1em;
peak: 1em;
border-radius: 50%;
background-color: #245e51;
transform-origin: middle;
will-change: remodel, background-color;
remodel: translate(0);
place-self: middle;
}
.section-resource {
coloration: #efeeec;
justify-content: middle;
align-items: middle;
show: flex;
place: absolute;
inset: 0;
}
.osmo-icon-svg {
width: 10em;
}
.osmo-icon__link {
coloration: currentColor;
text-decoration: none;
}
1. Register plugins
gsap.registerPlugin(InertiaPlugin);
2. Construct your grid & elective middle gap
First, wrap every little thing in an initGlowingInteractiveDotsGrid()
perform and declare your tweakable parameters—colours, glow distance, pace thresholds, shockwave settings, max pointer pace, and whether or not to carve out a middle gap for a emblem. We additionally arrange two arrays, dots and dotCenters, to trace the weather and their positions.
perform 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 wonderfully 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 house to your emblem; set centerHole = false
to fill each cell.
Inside buildGrid()
, we:
- Filter any current 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 possibly can place a emblem or focal ingredient.
perform buildGrid() {
container.innerHTML = "";
dots = [];
dotCenters = [];
const model = getComputedStyle(container);
const dotPx = parseFloat(model.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 every cell to create dots…
}
Now loop over each cell index. Inside that loop, we conceal 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 conceal it.
- In any other case, we place it at { x: 0, y: 0 } with the bottom coloration and mark it as not but sprung.
- Append it to the container and monitor it in dots.
// ... add this to the buildGrid() perform
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.model.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 middle coordinate—together with any scroll offset—so we are able to calculate distances later. Wrapping in requestAnimationFrame ensures the format is settled.
// ... add this to the buildGrid() perform
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.high + window.scrollY + r.peak / 2
};
});
});
// that is the top of the buildGrid() perform
By now, the entire buildGrid() perform will appear to be the next:
perform buildGrid() {
container.innerHTML = "";
dots = [];
dotCenters = [];
const model = getComputedStyle(container);
const dotPx = parseFloat(model.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.model.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.high + 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 consumer 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 pace to maxSpeed
to keep away from runaway values. Then, on the following animation body, we loop by every dot’s middle:
- Compute its distance to the cursor and derive
t = Math.max(0, 1 - dist / threshold)
. - Interpolate its coloration from
colours.base
tocolours.energetic
. - If
pace > 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 within our initGlowingInteractiveDotsGrid()
perform:
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 pace = Math.hypot(vx, vy)
if (pace > maxSpeed) {
const scale = maxSpeed / pace
vx = vx * scale
vy = vy * scale
pace = 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 (pace > 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,
period: 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 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 press 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,
period: 1.5,
ease: "elastic.out(1, 0.75)"
})
el._inertiaApplied = false
}
})
}
})
})
5. Placing all of it collectively
By now, all of our items dwell inside one initGlowingInteractiveDotsGrid() perform. Right here’s an abbreviated view of your remaining JS setup:
gsap.registerPlugin(InertiaPlugin);
perform 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 possibly can management begin/finish percentages, period, easing, and even stagger a number of paths. On this demo, we’ll connect a randomized scribble underline to every hyperlink on hover—good 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
Branding
Design
Improvement
physique {
background-color: #fefaee;
}
.section-resource {
show: flex;
justify-content: middle;
align-items: middle;
min-height: 100vh;
font-size: 1.5vw;
}
.text-draw {
coloration: #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;
coloration: #e55050;
}
.text-draw__box-svg {
place: absolute;
high: 0;
left: 0;
width: 100%;
peak: 100%;
overflow: seen !essential;
}
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 with its
. Once 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 advocate drawing (and thus creating the trail coordinates) within the order of which you need to draw them.
const svgVariants = [
``,
``,
``,
``,
``,
``
];
perform 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 hear for mouseenter
and mouseleave
. On hover-in, we:
- Forestall restarting if the earlier draw-in tween remains to be energetic.
- Kill any ongoing draw-out tween.
- Choose the following SVG variant (biking by the array).
- Inject it into the field, enhance 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, {
period: 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, {
period: 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() perform and name it as soon as the DOM is prepared:
perform 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 helps you to fluidly morph one SVG form into one other—even after they have completely different numbers of factors—by intelligently mapping anchor factors. You possibly can select the morphing algorithm (dimension, place or complexity), management easing, period, and even add rotation to make the transition really feel additional easy. 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 advocate diving into the docs for this plugin, as there are a complete 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
physique {
background-color: #0e100f;
coloration: #fffce1;
show: flex;
flex-direction: column;
align-items: middle;
justify-content: middle;
peak: 100vh;
margin: 0;
}
.play-pause-button {
background: clear;
border: none;
width: 10rem;
peak: 10rem;
show: flex;
align-items: middle;
justify-content: middle;
coloration: 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
ingredient 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 pace
- form set to the alternative icon’s path
Lastly, we flip isPlaying
so the following click on morphs again.
perform 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, {
period: 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 assets!
→ 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 somewhat 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 assets with them.
As a notice, we’re absolutely conscious that every one the HTML and markup within the article is somewhat concise, and positively less than commonplace with all finest practices for accessibility. To make these assets 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 personal.
Have a beautiful remainder of your day, or evening, wherever you’re. Glad animating!
Entry a rising library of assets

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