5 web animations that won't hurt your performance (with GSAP)
Five concrete GSAP animations that add no perceptible performance cost. Ready-to-use code, the properties to animate, and the ones to avoid.
A poorly built animation is expensive: it recalculates the page layout on every frame, saturates the main thread, and drags down the performance score you spent hours earning. The problem is rarely the animation itself. It’s the CSS property you chose to animate.
Here are five common GSAP animations, with their code, that stay smooth at 60 frames per second, even on an entry-level phone.
The principle behind everything else
A browser processes a page in three steps: layout (computing positions and sizes), paint (drawing pixels), composite (assembling layers). Animating width, height, top, left or box-shadow triggers layout and paint on every frame, which weighs directly on the main thread.
Animating transform (translate, scale, rotate) and opacity only triggers the composite step, one the browser delegates to the GPU. That’s the one rule that actually matters: every animation below only touches these two properties.
1. Fade and translate on scroll
The most common animation, and the most often done wrong. It’s applied to translateY and opacity, never to top or margin.
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
gsap.utils.toArray('.reveal').forEach((element) => {
gsap.from(element, {
y: 40,
opacity: 0,
duration: 0.6,
ease: 'power2.out',
scrollTrigger: {
trigger: element,
start: 'top 85%',
once: true, // the animation doesn't replay when scrolling back up
},
});
});
once: true avoids recomputing the animation every time the scroll passes over it, a detail that matters on a page with many sections.
2. Cascade reveal on a list of cards
To reveal several elements at once (services, projects, articles), a stagger gives a natural reading effect without spinning up one GSAP instance per element.
gsap.from('.card', {
y: 24,
opacity: 0,
duration: 0.5,
stagger: 0.08, // 80ms offset between each card
ease: 'power1.out',
scrollTrigger: {
trigger: '.card-grid',
start: 'top 80%',
once: true,
},
});
A single GSAP call animates the whole grid. It’s lighter than looping with one ScrollTrigger per card, and the visual result is identical.
3. A light hover effect on a button
A hover effect that slightly enlarges or shifts a button gives a sense of responsiveness, as long as it stays on scale and y.
document.querySelectorAll('.btn').forEach((button) => {
const tween = gsap.to(button, {
scale: 1.04,
y: -2,
duration: 0.2,
ease: 'power2.out',
paused: true,
});
button.addEventListener('mouseenter', () => tween.play());
button.addEventListener('mouseleave', () => tween.reverse());
});
Creating the tween once, in a paused state, avoids instantiating a new one on every hover. That’s what keeps the interaction instant, even on a page with many buttons.
4. A gentle parallax on an image
Parallax is often built with background-position, a property that triggers paint on every scroll frame. The performant version moves an element via transform, with scrub to sync the animation to the scroll.
gsap.to('.parallax-image', {
yPercent: 15, // relative offset, no layout recalculation
ease: 'none',
scrollTrigger: {
trigger: '.parallax-section',
start: 'top bottom',
end: 'bottom top',
scrub: 0.5, // follows the scroll with a slight lag
},
});
yPercent relies on transform: translateY() internally. The image should be slightly larger than its container (overflow: hidden on the parent) so the offset doesn’t leave a visible edge.
5. An animated counter for key figures
Animating a displayed number (“120 projects”, “98% satisfaction”) touches no CSS property at all: GSAP animates a plain JavaScript value, and the result gets written to the text on every frame.
document.querySelectorAll('.counter').forEach((element) => {
const target = Number(element.dataset.target);
const counter = { value: 0 };
gsap.to(counter, {
value: target,
duration: 1.2,
ease: 'power1.out',
scrollTrigger: {
trigger: element,
start: 'top 90%',
once: true,
},
onUpdate: () => {
element.textContent = Math.round(counter.value);
},
});
});
The real cost of this animation is the textContent rewrite on every frame, which stays negligible for a single number. Avoid applying it to a large block of text, though: there, the DOM update cost becomes noticeable again.
The habit worth keeping
Before adding an animation, one question is enough: does it only touch transform and opacity? If the answer is no, there’s almost always a variant that does. The second habit is to measure: the Performance tab in DevTools shows within seconds whether an animation triggers layout, and Lighthouse penalises a degraded performance score directly, a topic I cover in more depth in my article on Core Web Vitals.
It’s the same discipline I apply to my own projects, described in more detail in why I chose Astro and GSAP for my portfolio: animations that add fluidity, never a performance handicap.
Let’s talk about your project
Want polished animations on your site without sacrificing load speed? Tell me about your project: I’ll show you what’s possible within a showcase website build designed to stay fast.