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.
Add to cart
Section titled “Add to cart”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()andtl.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.
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)' }, '<'); });}<header> <strong>Northwind</strong> <button type="button" class="cart" aria-label="Cart, 0 items"> 🛒 <span class="badge" aria-hidden="true">0</span> </button></header>
<main> <article class="product"> <div class="image" style="--tone: #ffd166">🎧</div> <h2>Studio headphones</h2> <p>$129</p> <button type="button" class="add">Add to cart</button> </article> <article class="product"> <div class="image" style="--tone: #06d6a0">⌚</div> <h2>Field watch</h2> <p>$89</p> <button type="button" class="add">Add to cart</button> </article> <article class="product"> <div class="image" style="--tone: #ef476f">👟</div> <h2>Trail runners</h2> <p>$110</p> <button type="button" class="add">Add to cart</button> </article></main>
<style> body { display: block; place-items: normal; background: #f6f3ee; color: #1d1b26; } header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: white; box-shadow: 0 1px 0 #0000001a; } .cart { position: relative; padding: 0.4rem 0.6rem; border: 0; border-radius: 0.5rem; background: #f6f3ee; font-size: 1.4rem; cursor: pointer; } .badge { position: absolute; top: -0.35rem; right: -0.45rem; min-width: 1.3rem; padding: 0.1rem 0.3rem; border-radius: 999px; background: #1d1b26; color: white; font: 700 0.75rem/1.1rem system-ui, sans-serif; } main { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; padding: 1.5rem; } .product { padding: 0.75rem; border-radius: 0.75rem; background: white; } .image { display: grid; place-items: center; aspect-ratio: 4 / 3; border-radius: 0.5rem; background: var(--tone); font-size: 3rem; } h2 { margin: 0.75rem 0 0.25rem; font-size: 1rem; } p { margin: 0 0 0.75rem; opacity: 0.6; } .add { width: 100%; padding: 0.6rem; border: 0; border-radius: 0.5rem; background: #1d1b26; color: white; font: 600 0.9rem system-ui, sans-serif; cursor: pointer; } .flyer { position: fixed; z-index: 1; display: grid; place-items: center; border-radius: 0.5rem; pointer-events: none; }</style>Daily goals
Section titled “Daily goals”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.
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.';}<section class="goals"> <header> <div class="progress"> <svg viewBox="0 0 100 100" aria-hidden="true"> <circle class="track" cx="50" cy="50" r="42" /> <circle class="value" cx="50" cy="50" r="42" /> </svg> <span class="count">0/6</span> </div> <div> <h1>Today</h1> <p class="status">Six small things.</p> </div> </header>
<ul> <li><label><input type="checkbox" /> Drink a glass of water</label></li> <li><label><input type="checkbox" /> Stretch for five minutes</label></li> <li><label><input type="checkbox" /> Reply to one old message</label></li> <li><label><input type="checkbox" /> Walk around the block</label></li> <li><label><input type="checkbox" /> Read ten pages</label></li> <li><label><input type="checkbox" /> Plan tomorrow</label></li> </ul>
<label class="still"><input type="checkbox" /> Still frames (reduced motion)</label></section>
<style> .goals { width: min(24rem, 100vw - 2rem); padding: 1.5rem; border-radius: 1rem; background: #2d1b3d; } header { display: flex; gap: 1rem; align-items: center; } .progress { position: relative; display: grid; place-items: center; width: 4.5rem; height: 4.5rem; } .progress svg { position: absolute; inset: 0; rotate: -90deg; } circle { fill: none; stroke-width: 10; } .track { stroke: #ffffff1a; } .value { stroke: #ffd166; stroke-linecap: round; stroke-dasharray: 264; stroke-dashoffset: 264; } .count { font-weight: 700; } h1 { margin: 0; font-size: 1.4rem; } .status { margin: 0.2rem 0 0; opacity: 0.7; } ul { margin: 1.25rem 0; padding: 0; list-style: none; } li label { display: flex; gap: 0.75rem; align-items: center; padding: 0.5rem 0; cursor: pointer; } input { width: 1.1rem; height: 1.1rem; accent-color: #06d6a0; } li:has(input:checked) label { opacity: 0.55; text-decoration: line-through; } .still { display: flex; gap: 0.5rem; align-items: center; font-size: 0.85rem; opacity: 0.7; }</style>Year in review
Section titled “Year in review”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: sectionpaints each burst inside its section, so it scrolls with it and is clipped by it.- A fixed
seedper section means the fireworks are the same on every visit. Within one tween they never change anyway, however often they are scrubbed.
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);});<section class="intro"> <p>Your year on motly-labs</p> <h1>2026, wrapped</h1> <p class="hint">Scroll ↓</p></section>
<section class="stat" style="--tone: #ffd166"> <p>You shipped</p> <strong data-to="1248">0</strong> <p>commits</p></section>
<section class="stat" style="--tone: #06d6a0"> <p>across</p> <strong data-to="86">0</strong> <p>releases</p></section>
<section class="stat" style="--tone: #ef476f"> <p>and picked up</p> <strong data-to="12400">0</strong> <p>stars</p></section>
<section class="outro"> <h2>See you in 2027.</h2> <p class="hint">Scroll back up: every burst runs back exactly.</p></section>
<style> body { display: block; place-items: normal; background: #120a1c; } section { position: relative; display: grid; place-content: center; min-height: 100vh; overflow: hidden; text-align: center; } h1 { margin: 0.25rem 0; font-size: 2.5rem; } p { margin: 0; opacity: 0.75; } .hint { margin-top: 1.5rem; opacity: 0.5; } strong { margin: 0.25rem 0; color: var(--tone); font-size: 4rem; font-variant-numeric: tabular-nums; }</style>Chat reactions, in React
Section titled “Chat reactions, in React”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, andcontextSafe()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.
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 />);<div id="app"></div>
<style> .chat { display: grid; gap: 0.75rem; width: min(28rem, 100vw - 2rem); } .message { padding: 0.75rem 1rem; border-radius: 1rem; background: #2d1b3d; } .meta { display: flex; justify-content: space-between; font-size: 0.8rem; opacity: 0.6; } .meta button { padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; } .message p { margin: 0.35rem 0 0.6rem; } .reactions { display: flex; gap: 0.4rem; } .reactions button { padding: 0.2rem 0.55rem; border: 1px solid #ffffff26; border-radius: 999px; background: #ffffff0d; color: inherit; font: 0.9rem system-ui, sans-serif; cursor: pointer; } .reactions button[aria-pressed='true'] { border-color: #ffd166; background: #ffd16626; } .restore { justify-self: center; padding: 0.4rem 0.9rem; border: 1px solid #ffffff44; border-radius: 999px; background: none; color: inherit; cursor: pointer; }</style>It uses @gsap/react for useGSAP():
npm install @motly/gsap gsap @gsap/react