GSAP WordPress Performance Guide

A practical guide to adding GSAP to WordPress marketing sites without avoidable loading, main-thread, responsive, or accessibility costs.


When someone searches for GSAP WordPress performance, they are usually not asking how to make a demo move. They are deciding whether a WordPress marketing site needs GSAP, where it should load, why a scroll effect is janky, or how to keep motion from becoming an accessibility and maintenance problem.

This is a focused implementation and troubleshooting guide for that decision. It starts at the shipped browser experience: conditional script loading, scoped animation, layout stability, reduced motion, and repeatable testing. It is deliberately narrower than my Figma-to-WordPress theme workflow, which starts with design handoff, content modelling, templates, and responsive implementation. A Figma workflow may identify motion; this article is about whether and how that motion should reach a real WordPress visitor.

1. I decide whether GSAP is needed at all

I do not treat animation as a requirement of every marketing site. A page can feel considered through typography, spacing, image direction, and a clear interaction model without shipping a JavaScript animation library.

I start by writing down the purpose and the exit condition for each effect:

Interaction need First option I consider
Hover, focus, or a short state transition CSS transition, when it is enough
A sequenced entrance or coordinated state change GSAP timeline
An element responding to scroll position ScrollTrigger, only for the elements that need it
Decorative movement with no communication benefit No animation

This keeps the scope small. A hero timeline may be worthwhile if it establishes hierarchy once. It is harder to justify animating every card, icon, heading, and background on every visit. The more effects a page has, the more states I need to test, the more work the main thread may do, and the more future editors need to understand.

For a project that needs a deliberate custom theme and interaction system, custom WordPress development can provide a good place to make those boundaries explicit. It is not, however, a reason to add GSAP by default.

2. I load motion only where the page needs it

A deferred script is still downloaded. defer prevents parser-blocking execution, but it does not make an asset free. The first WordPress decision is therefore conditional enqueueing: do not send a motion bundle to pages that have no motion component.

The following example targets a custom theme’s front page and a named marketing template. It assumes WordPress 6.3 or later, where the current wp_enqueue_script() arguments support a loading strategy. The filemtime() version makes a rebuilt asset easier to cache-bust without changing the enqueue logic.

<?php
function marga_enqueue_theme_motion() {
    if ( ! is_front_page() && ! is_page_template( 'template-marketing.php' ) ) {
        return;
    }

    $asset_path = get_theme_file_path( '/assets/js/theme-motion.js' );

    if ( ! file_exists( $asset_path ) ) {
        return;
    }

    wp_enqueue_script(
        'marga-theme-motion',
        get_theme_file_uri( '/assets/js/theme-motion.js' ),
        [],
        (string) filemtime( $asset_path ),
        [
            'strategy'  => 'defer',
            'in_footer' => true,
        ]
    );
}
add_action( 'wp_enqueue_scripts', 'marga_enqueue_theme_motion' );

The condition should match the actual ownership of the component. A site that uses the effect in a block template might use a reliable block or template check instead. I prefer a server-side condition over wp_is_mobile(): responsive behavior belongs in the browser, while whether the bundle is relevant belongs to the page being rendered.

async is usually the wrong default for a bundle that has a known relationship with markup or other scripts because execution order is not guaranteed. defer preserves document order and waits until parsing has finished. It is still worth checking the generated HTML and the Network panel rather than assuming the loading strategy was applied as expected.

3. I bundle one small, versioned entry point

For the reproducible example below, the prerequisites are:

  • a custom WordPress theme with a Node-based build step
  • WordPress 6.3 or later for the script arguments above
  • GSAP 3.15.0, installed as an exact dependency
  • a bundler that can resolve npm imports and write assets/js/theme-motion.js

With a small esbuild-based theme, the setup can be as explicit as:

pnpm add --save-exact gsap@3.15.0
pnpm add --save-dev esbuild
pnpm exec esbuild src/theme-motion.js --bundle --format=iife --minify --outfile=assets/js/theme-motion.js

The source entry point expects a deliberately scoped piece of markup. The data-motion-layout attribute marks only images whose final dimensions matter to the scroll measurements in this example. Width and height should still be real values from the asset, not arbitrary placeholders.

<main data-theme-motion>
  <section data-motion-reveal>
    <p>Implementation-led marketing websites.</p>
  </section>

  <section data-motion-reveal>
    <h2>A section with a purposeful entrance.</h2>
  </section>

  <img
    data-motion-layout
    src="/wp-content/themes/example/assets/images/hero.webp"
    width="1600"
    height="900"
    alt="People collaborating around a product dashboard"
  />
</main>

Here is the corresponding src/theme-motion.js. It imports GSAP and ScrollTrigger from the same pinned GSAP 3.15.0 package, uses gsap.matchMedia() for viewport and reduced-motion conditions, and never queries or animates outside the page root.

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

const root = document.querySelector("[data-theme-motion]");

if (root) {
  const media = gsap.matchMedia();
  const context = gsap.context(() => {
    media.add(
      {
        all: "(min-width: 0px)",
        desktop: "(min-width: 768px)",
        reduced: "(prefers-reduced-motion: reduce)",
      },
      ({ conditions }) => {
        const { desktop, reduced } = conditions;
        const reveals = root.querySelectorAll("[data-motion-reveal]");

        if (reduced) {
          gsap.set(reveals, {
            clearProps: "transform,opacity,visibility",
          });
          return;
        }

        reveals.forEach((element) => {
          gsap.fromTo(
            element,
            { autoAlpha: 0, y: desktop ? 24 : 12 },
            {
              autoAlpha: 1,
              y: 0,
              duration: desktop ? 0.65 : 0.45,
              ease: "power2.out",
              overwrite: "auto",
              scrollTrigger: {
                trigger: element,
                start: desktop ? "top 82%" : "top 90%",
                once: true,
              },
            },
          );
        });
      },
      root,
    );
  }, root);

  let destroyed = false;

  function imageReady(image) {
    if (image.complete) {
      return image.decode
        ? image.decode().catch(() => undefined)
        : Promise.resolve();
    }

    return new Promise((resolve) => {
      image.addEventListener("load", resolve, { once: true });
      image.addEventListener("error", resolve, { once: true });
    });
  }

  function refreshAfterLayoutResources() {
    const images = Array.from(root.querySelectorAll("img[data-motion-layout]"));
    const fontsReady = document.fonts
      ? document.fonts.ready.catch(() => undefined)
      : Promise.resolve();

    Promise.all([fontsReady, ...images.map(imageReady)]).then(() => {
      if (
        destroyed ||
        window.matchMedia("(prefers-reduced-motion: reduce)").matches
      ) {
        return;
      }

      requestAnimationFrame(() => {
        if (!destroyed) {
          ScrollTrigger.refresh();
        }
      });
    });
  }

  function handlePageHide(event) {
    if (!event.persisted) teardown();
  }

  function teardown() {
    if (destroyed) return;
    destroyed = true;
    window.removeEventListener("pagehide", handlePageHide);
    media.revert();
    context.revert();
  }

  refreshAfterLayoutResources();
  window.addEventListener("pagehide", handlePageHide);
}

This is a browser implementation example, not code already used by this repository’s WordPress site. GSAP’s ScrollTrigger documentation explains the plugin’s trigger and refresh model, but the important performance habit here is scope: one root, only the marked elements, and no global ScrollTrigger.getAll().kill() that could disrupt another component.

The always-matching all condition ensures that the callback also runs on a narrow viewport with normal motion; desktop and reduced then select the behavior. The matchMedia() callback is also a lifecycle boundary. When the viewport or motion preference changes, GSAP can revert the animations created in that condition and create the appropriate condition again. media.revert() handles the matchMedia collection during teardown, while context.revert() restores GSAP-managed inline changes.

The pagehide handler tears down a document that is actually leaving, but deliberately preserves one entering the browser’s back/forward cache so its effects can resume if restored. A PJAX router, block preview, or other system that replaces the root without a normal navigation should call teardown() from its own before-replace lifecycle instead. If a theme adds its own event listeners, timers, or observers, return a cleanup function from the relevant matchMedia callback or remove those resources in teardown() too.

The reduced-motion branch deliberately does not create ScrollTriggers or hide content first. Essential content remains visible when motion is reduced, when JavaScript fails, and when a visitor is using a device or browser where the enhancement is unavailable. autoAlpha is useful for the animated path, but it should not become a prerequisite for reading the page.

4. I keep animation away from layout when possible

The browser guidance is general; WordPress does not change how layout and paint work. GSAP can make a property easy to interpolate, but it cannot make a layout-changing property free.

Property or technique Usual concern Better starting point
transform and opacity Often compositor-friendly, but large layers can still consume memory or paint Use small, scoped effects and verify in a trace
top, left, width, height, margin, or grid values May trigger layout work and invalidate surrounding content Reserve the layout, then animate transform or opacity
Large blur, filters, shadows, or complex SVG paths Can be paint- or rasterisation-heavy even when layout is unchanged Reduce the area, simplify the asset, or use a static state
will-change everywhere Extra layers and memory can cost more than they save Add it only for a short, measured reason, or omit it

“Transforms and opacity are cheap” is useful shorthand, not a guarantee. A full-screen translucent layer, a huge filtered image, or dozens of simultaneous elements can still make rendering expensive. I use the Performance panel to confirm what the browser is actually doing instead of relying on a property list.

I also avoid using animation to reveal space that the layout has not reserved. An image needs intrinsic width and height, an aspect-ratio box, or an equivalent stable container. A font swap, late-loaded embed, consent banner, or editor-inserted block can move a ScrollTrigger start position after it was measured. That is why the example waits for the selected fonts and layout-affecting images before its one deliberate ScrollTrigger.refresh().

The same principle applies to a sticky header. Translating a header vertically can avoid repeatedly changing document flow, while changing a background color or shadow at a meaningful state change may be reasonable. In this repository, Header.astro uses gsap.killTweensOf before menu transitions and uses overwrite: "auto" where competing header and navigation tweens could otherwise fight. That is a practical way to handle interrupted interaction; it is not a claim that the component has a WordPress performance benchmark.

5. I make responsive behavior and reduced motion explicit

A desktop animation copied onto a narrow viewport can be needlessly long, visually crowded, or difficult to follow. I use gsap.matchMedia() when the animation itself needs different values or should disappear at a breakpoint. I do not use it to guess the device from a user agent.

The example uses a smaller offset and shorter duration on narrow screens. A real component might use a different trigger start, a smaller stagger, or no scroll animation at all. The point is to define the behavior rather than let a desktop default leak into every viewport.

Reduced motion is a user preference, not a performance mode that I can assume visitors want. I test both the operating-system preference and any product-level motion setting. A good no-animation path should:

  • keep headings, controls, and meaningful media visible
  • avoid requiring a visitor to wait for a transition before acting
  • preserve keyboard focus and the reading order
  • remove nonessential scroll, parallax, and looping effects
  • avoid replacing a motion cue with a hidden or inaccessible state

CSS can cover known, nonessential transitions as a second line of defence. I scope that override to the motion component rather than changing every widget and third-party element on the page:

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  [data-theme-motion] [data-motion-reveal],
  [data-theme-motion] [data-motion-reveal]::before,
  [data-theme-motion] [data-motion-reveal]::after {
    animation: none !important;
    transition: none !important;
  }
}

That rule does not replace an explicit JavaScript reduced-motion branch. A GSAP timeline can set inline transforms or opacity, and a ScrollTrigger can hold a hidden start state; the script still needs to avoid creating that hidden state for a reduced-motion visitor.

6. I choose assets that leave room for the page

GSAP controls a timeline. It does not make a large image, video, font, or Lottie file smaller. I choose assets with the same care I give the animation code:

  • provide responsive image sources with realistic sizes and modern formats where supported
  • include intrinsic dimensions or an aspect-ratio wrapper before an image loads
  • avoid shipping a video or complex animated SVG when a static image communicates the same thing
  • load a motion asset only on the template that uses it
  • avoid animating every path in a large SVG if a transform on a containing element communicates the idea
  • preload only a genuinely critical asset, and verify that it is not duplicated later

Integrx homepage hero with an integration message, site navigation, and connected platform illustrations

Integrx is a public implementation reference using GSAP, not a performance benchmark. The image shows the kind of WordPress marketing-site surface where motion, assets, and layout stability need to be considered together.

In this repository, package.json declares GSAP ^3.15.0, and pnpm-lock.yaml resolves 3.15.0. Hero.astro uses a GSAP timeline and, when reduced motion is enabled, sets the hero grid to its final visible state instead of running the reveal. Header.astro demonstrates interruption control with gsap.killTweensOf and overwrite behavior. Those are useful implementation references, but this Astro project is not a WordPress test site, it does not provide a ScrollTrigger benchmark, and I have not measured WordPress or Core Web Vitals results from it.

7. I test a motion decision against a no-motion baseline

I do not call an animation “performant” because it feels smooth on my development laptop. I compare the production page with the effect enabled against the same page with the effect removed or reduced to its functional state. The result is evidence for this page and this device mix, not a universal guarantee.

Test dimension Repeatable comparison
Build Production build, compressed assets, no development server or source maps served to visitors
Session Logged-out page with no WordPress admin bar; test a cold load and a warm-cache load
Motion Animated version against a no-animation baseline; normal motion against reduced motion
Viewport Narrow and wide viewports, including a width between the supplied design frames
Network Inspect transferred bytes, request priority, timing, duplicates, and whether GSAP or its plugin loads on unrelated pages
Runtime Inspect Performance-panel long tasks, scripting, rendering, layout, paint, and dropped frames during scroll and interaction
Lab audit Run Lighthouse repeatedly and record the median rather than trusting one run
Stability Check CLS and visible layout shifts while fonts, images, embeds, and WordPress content arrive
Field evidence Review real-user data or CrUX where available, segmented by device and page type

I test the page both with the admin bar absent and with realistic content changes. The admin bar can change the top offset and mask a spacing problem, while a longer editor heading or a different image crop can change the layout that ScrollTrigger measured.

For a scroll effect, I record where the trigger starts, whether the element is already visible when the visitor arrives, and what happens when a visitor scrolls quickly past it. In the Performance panel, I look for long tasks and rendering work during the actual gesture, not only the initial load. In Lighthouse, I use repeated runs and compare medians. I treat field data as more useful than a single lab score when it is available, but I still segment it: a mobile landing page and an editor-heavy desktop page are different cases.

For more background on the main-thread side of this work, web.dev’s guide to optimizing long tasks is a useful browser-level reference. It is general performance guidance, not a WordPress-specific promise. No test table can guarantee a particular Core Web Vitals result for every host, browser, network, or future content change.

8. I choose the least complicated tool that meets the interaction

My practical decision guide looks like this:

  1. Use CSS for a hover, focus, color, or simple opacity transition that does not need sequencing or scroll state.
  2. Use no animation when the effect is decorative, competes with the message, or adds more testing and maintenance than value.
  3. Use a small GSAP timeline when several elements need one coordinated, interruptible state change or when CSS would make the state logic harder to maintain.
  4. Use ScrollTrigger carefully when scroll position is genuinely part of the interaction. Scope it, reserve layout space, handle reduced motion, and clean it up.
  5. Measure before keeping it when the animation adds a large asset, repeated work, pinning, filters, or many independently animated elements.

That tradeoff is especially important in WordPress. A page may gain a polished entrance but lose more through a bundle sent to every template, an image that shifts the hero, or an editor adding enough repeated blocks to multiply the work. The right implementation is often a static hero with one small enhancement rather than a motion system across the whole site.

If you are deciding whether a WordPress marketing site needs custom motion, I can start with the page goals, content workflow, and testing constraints rather than assume GSAP is the answer. Contact me with the current site, design, or troublesome interaction and I can help define the smallest implementation worth shipping.