Skip to content

Real-world examples

Four small but complete interfaces, each the kind of screen people ship. Each one combines several things the small examples show one at a time. Open one on its own page for more room.

A product image flies to the cart along an arc, a ring and a pop of confetti in the product’s color land on the cart, and the count bumps. It is one GSAP timeline across three elements:

  • tl.shape() and tl.burst() sit in the timeline after the flight, placed with GSAP’s position parameter.
  • The cart is an Anchor, and its position is read when its burst starts, not when the timeline is built, so resizing the page mid-flight still lands the burst on the cart.
  • The pop is a Spec built by a function, pop(tone), from the product’s color.
  • Under reduced motion the flight is skipped and the bursts show their Resting frame.
Open full page ↗
import { each, Motly, rand } from '@motly/gsap';
import { gsap } from 'gsap';
gsap.registerPlugin(Motly);
// What lands on the cart: a ring, and a pop of confetti in the product's color.
const ring = {
kind: 'circle',
radius: [10, 34],
fill: 'none',
stroke: '#1d1b26',
strokeWidth: [4, 0],
duration: 0.45,
restAt: 0.3,
};
const pop = (tone) => ({
kind: 'burst',
count: 12,
radius: [10, rand(38, 50)],
easing: 'ease-out',
restAt: 0.35,
children: {
kind: 'star',
points: 4,
innerRadius: 0.4,
radius: [rand(3, 6), 0],
angle: [0, rand(-120, 120)],
fill: each([tone, '#1d1b26']),
duration: rand(0.45, 0.7),
},
});
const cart = document.querySelector('.cart');
const badge = cart.querySelector('.badge');
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
let count = 0;
for (const button of document.querySelectorAll('.add')) {
button.addEventListener('click', () => {
const image = button.closest('.product').querySelector('.image');
const tone = getComputedStyle(image).getPropertyValue('--tone').trim();
// A copy of the product image flies to the cart; the page itself does not move.
const from = image.getBoundingClientRect();
const to = cart.getBoundingClientRect();
const flyer = image.cloneNode(true);
flyer.classList.add('flyer');
Object.assign(flyer.style, {
left: `${from.left}px`,
top: `${from.top}px`,
width: `${from.width}px`,
height: `${from.height}px`,
});
document.body.append(flyer);
// The flight is GSAP's; under reduced motion it jumps straight to the cart.
const flight = reduceMotion.matches ? 0 : 0.7;
const x = to.left + to.width / 2 - (from.left + from.width / 2);
const y = to.top + to.height / 2 - (from.top + from.height / 2);
gsap
.timeline()
.to(button, { scale: 0.95, duration: 0.08, yoyo: true, repeat: 1 })
// x and y on different eases make the path an arc.
.to(flyer, { x, duration: flight, ease: 'power1.inOut' }, 0)
.to(flyer, { y, duration: flight, ease: 'back.in(1.4)' }, 0)
.to(flyer, { scale: 0.12, borderRadius: '50%', duration: flight, ease: 'power2.in' }, 0)
.call(() => flyer.remove())
// The cart's position is read when its bursts start, not when the timeline was built.
.shape(cart, { spec: ring })
.burst(cart, { spec: pop(tone) }, '<')
.call(
() => {
count += 1;
badge.textContent = String(count);
cart.setAttribute('aria-label', `Cart, ${count} ${count === 1 ? 'item' : 'items'}`);
},
[],
'<',
)
.fromTo(badge, { scale: 1.7 }, { scale: 1, duration: 0.5, ease: 'back.out(3)' }, '<');
});
}

A checklist with a progress ring. Each task bursts from its checkbox when done, and unchecking it plays that burst backward into the checkbox. The ring bursts at a third, two thirds and all done, bigger each time.

  • Each milestone’s Spec is built from the progress in code: more Children, further out, and stars at the end. A Spec is plain data.
  • tween.reverse() on a finished burst runs it back, as it would any GSAP tween.
  • The switch at the bottom passes reducedMotion: 'always', to show what a viewer who prefers reduced motion sees: each burst’s Resting frame, for as long as the burst would have played.
Open full page ↗
import { each, Motly, rand } from '@motly/gsap';
import { gsap } from 'gsap';
gsap.registerPlugin(Motly);
const palette = ['#ffd166', '#06d6a0', '#ef476f', '#118ab2'];
// A small burst from a checkbox as its task is done.
const tick = {
kind: 'burst',
count: 8,
radius: [6, 26],
restAt: 0.4,
children: { kind: 'circle', radius: [rand(2, 3.5), 0], fill: '#06d6a0', duration: 0.45 },
};
// A Spec is plain data, so it can be built from the progress: each milestone is bigger.
// All done gets stars; a circle takes no `points`, so the piece is chosen first.
const milestone = (level) => ({
kind: 'burst',
count: 6 + level * 6,
radius: [20, 40 + level * 30],
easing: 'ease-out',
restAt: 0.35,
children: {
...(level === 3 ? { kind: 'star', points: 5 } : { kind: 'circle' }),
radius: [rand(3, 3 + level * 2), 0],
angle: [0, rand(-180, 180)],
fill: each(palette),
duration: rand(0.6, 0.6 + level * 0.3),
},
});
const boxes = [...document.querySelectorAll('li input')];
const still = document.querySelector('.still input');
const value = document.querySelector('.value');
const count = document.querySelector('.count');
const status = document.querySelector('.status');
const ticks = new Map();
let reached = 0;
// Left out, reducedMotion follows the viewer's setting; the switch forces still frames.
const reducedMotion = () => (still.checked ? 'always' : 'user');
for (const box of boxes) {
box.addEventListener('change', () => {
if (box.checked) {
ticks.set(box, gsap.effects.burst(box, { spec: tick, reducedMotion: reducedMotion() }));
} else {
// Unchecking plays the task's burst backward, back into its checkbox.
ticks.get(box)?.reverse();
}
update();
});
}
function update() {
const done = boxes.filter((box) => box.checked).length;
const progress = done / boxes.length;
count.textContent = `${done}/${boxes.length}`;
gsap.to(value, { strokeDashoffset: 264 * (1 - progress), duration: 0.5, ease: 'power2.out' });
// A burst from the ring at a third, two thirds and all done; only on the way up.
const level = Math.floor(progress * 3);
if (level > reached) {
gsap.effects.burst('.progress', {
spec: milestone(level),
delay: 0.3,
reducedMotion: reducedMotion(),
});
}
reached = level;
status.textContent =
level === 3 ? 'All done. Nice.' : level > 0 ? 'Keep going.' : 'Six small things.';
}

A scroll story in the style of a yearly recap. As each stat scrolls in, its number counts up and fireworks open behind it; scroll back and both run backward, exactly.

  • ScrollTrigger scrubs each section’s timeline: the scroll position is the Playhead.
  • container: section paints each burst inside its section, so it scrolls with it and is clipped by it.
  • A fixed seed per section means the fireworks are the same on every visit. Within one tween they never change anyway, however often they are scrubbed.
Open full page ↗
import { each, Motly, rand } from '@motly/gsap';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(Motly, ScrollTrigger);
// Fireworks behind a number: bursts of bursts, in the section's own color.
const fireworks = (tone) => ({
kind: 'burst',
count: 7,
radius: [0, rand(110, 150)],
easing: 'ease-out',
restAt: 0.45,
children: {
kind: 'burst',
count: 10,
radius: [0, rand(25, 45)],
delay: rand(0, 0.2),
children: {
kind: 'circle',
radius: [rand(3, 5.5), 0],
fill: each([tone, '#ffffff']),
duration: rand(0.7, 1.1),
},
},
});
gsap.utils.toArray('.stat').forEach((section, index) => {
const number = section.querySelector('strong');
const tone = getComputedStyle(section).getPropertyValue('--tone').trim();
const counter = { value: 0 };
gsap
.timeline({
// Scrubbed: the scroll position is the Playhead, forward and back.
scrollTrigger: { trigger: section, start: 'top 70%', end: 'center 40%', scrub: 0.5 },
})
.to(counter, {
value: Number(number.dataset.to),
duration: 1,
ease: 'power2.out',
onUpdate: () => {
number.textContent = Math.round(counter.value).toLocaleString('en');
},
})
// Painted inside the section, so it scrolls with it. A fixed seed per section keeps each
// burst the same however often it is scrubbed.
.burst(number, { spec: fireworks(tone), container: section, seed: 2026 + index }, 0.6);
});

A chat thread where each reaction sends a burst of its own: hearts, confetti or a thumbs-up pop. Delete a message while its burst is still flying, and the burst goes with it.

  • useGSAP() gives each message a GSAP context, and contextSafe() puts every burst the message creates in it. When the message unmounts, the context is reverted and its bursts are cleared.
  • The Specs live outside the components: they are data, not state.
Open full page ↗
import { useGSAP } from '@gsap/react';
import { each, Motly, rand } from '@motly/gsap';
import { gsap } from 'gsap';
import { useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
gsap.registerPlugin(useGSAP, Motly);
const heart =
'M50 88C22 66 6 50 6 32C6 18 17 8 30 8C39 8 46 13 50 20C54 13 61 8 70 8C83 8 94 18 94 32C94 50 78 66 50 88Z';
// The confetti's colors, handed out in turn, and a random fall for each piece.
const confetti = { fill: each(['#ffd166', '#06d6a0', '#ef476f']), duration: rand(0.7, 1) };
/** One Spec per reaction: plain data, so it lives outside the components. */
const reactions = {
'❤️': {
kind: 'burst',
count: 8,
radius: [10, 55],
restAt: 0.4,
children: {
kind: 'path',
d: heart,
radius: [rand(5, 8), 0],
fill: '#ef476f',
duration: rand(0.6, 0.9),
},
},
'🎉': {
kind: 'burst',
count: 14,
radius: [10, rand(60, 80)],
easing: 'ease-out',
restAt: 0.35,
children: each([
{
kind: 'star',
points: 5,
radius: [rand(3, 6), 0],
angle: [0, rand(-270, 270)],
...confetti,
},
{ kind: 'polygon', points: 4, radius: [rand(3, 5), 0], angle: [0, 90], ...confetti },
]),
},
'👍': {
kind: 'burst',
count: 6,
radius: [10, 45],
restAt: 0.4,
children: { kind: 'circle', radius: [rand(3, 5), 0], fill: '#118ab2', duration: 0.6 },
},
};
const thread = [
{ id: 1, author: 'Ada', text: 'The release is out. Provenance and all.' },
{ id: 2, author: 'Lin', text: 'Docs site next? People keep asking how Swirl works.' },
{ id: 3, author: 'Sam', text: 'Try deleting a message mid-burst: nothing is left behind.' },
];
function Message({ message, onDelete }) {
const scope = useRef(null);
const [picked, setPicked] = useState({});
// Every burst made through contextSafe belongs to this component's GSAP context, which useGSAP
// reverts on unmount: delete the message mid-burst and its burst is cleared with it.
const { contextSafe } = useGSAP({ scope });
const react = contextSafe((emoji, button) => {
const on = !picked[emoji];
setPicked({ ...picked, [emoji]: on });
if (on) gsap.effects.burst(button, { spec: reactions[emoji] });
});
return (
<article className="message" ref={scope}>
<div className="meta">
<span>{message.author}</span>
<button type="button" onClick={() => onDelete(message.id)}>
Delete
</button>
</div>
<p>{message.text}</p>
<div className="reactions">
{Object.keys(reactions).map((emoji) => (
<button
type="button"
key={emoji}
aria-pressed={Boolean(picked[emoji])}
onClick={(event) => react(emoji, event.currentTarget)}
>
{emoji} {picked[emoji] ? 1 : ''}
</button>
))}
</div>
</article>
);
}
function Chat() {
const [messages, setMessages] = useState(thread);
const remove = (id) => setMessages(messages.filter((message) => message.id !== id));
return (
<section className="chat">
{messages.map((message) => (
<Message key={message.id} message={message} onDelete={remove} />
))}
{messages.length < thread.length && (
<button type="button" className="restore" onClick={() => setMessages(thread)}>
Restore messages
</button>
)}
</section>
);
}
createRoot(document.querySelector('#app')).render(<Chat />);

It uses @gsap/react for useGSAP():

Terminal window
npm install @motly/gsap gsap @gsap/react