Shopify themes ship with a set of animation toggles, and then they stop. You can turn on reveal-on-scroll in the theme settings, but you cannot scrub an animation to scroll position, split a heading into characters, or make product cards react to the cursor. The app store will sell you a monthly subscription for a fraction of that.

GSAP covers the rest, and it needs no app, no build step, and no theme framework. This guide covers where the code goes in a Shopify theme, the theme editor behaviour that breaks most tutorials you’ll find, and a full worked example: a collection grid that reveals in a staggered wave as it scrolls into view.

If you want to compare scroll animation approaches before committing to a library, Animate on Scroll: 3 Approaches Compared covers the trade-offs.

What Shopify themes already do

Modern Shopify themes handle more than they used to. Dawn and most themes built on it include:

Reveal-on-scroll animations. A theme setting that fades and lifts sections as they enter the viewport, applied consistently across the store.

Image hover behaviour. Zoom on hover, or swapping to the second product image on a collection card.

Transition settings. Theme-level controls for animation speed and whether animations run at all.

Reduced motion handling. Well-built themes disable their own animations for visitors who prefer reduced motion.

For a lot of stores that is genuinely enough. A clean product page that loads fast will outsell a slow one with a scroll-scrubbed hero. Reach for custom code when the effect is doing real work: signalling quality on a premium product, or holding attention on a long-form launch page.

Where the theme settings stop

Some effects can’t be produced by a settings panel, no matter which theme you buy:

Text splitting. Animating a heading word by word or character by character means each unit needs its own element. Theme settings animate whole blocks. GSAP’s SplitText handles the split and puts the DOM back afterwards, so the original text stays readable to crawlers and screen readers.

Scroll-scrubbed motion. A theme reveal plays when you reach a point. Scrubbing ties animation progress to scroll position, so scrolling back reverses it. That is the mechanic behind every pinned product story you’ve seen on a premium brand’s site.

Cursor-driven effects. Magnetic buttons, spotlight masks, trails that follow the pointer between collection cards. Themes respond to hover on and hover off, not to continuous coordinates.

Sequencing with conditions. Play A, then B, but only once the visitor has passed section C, and pause on hover. Timelines make that ordinary; settings panels do not expose it.

Per-card motion inside a loop. Staggering a collection grid so items cascade with the layout, rather than every card fading at once, needs code that can read the rendered grid.

Where custom code goes in a Shopify theme

There are three places, and choosing wrongly is how customisations disappear.

theme.liquid, for the library only

Open Online Store → Themes → ⋯ → Edit code, then layout/theme.liquid, and add the CDN tags just before </body>:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>

The version is pinned so a future GSAP release can’t change behaviour without you choosing to update. GSAP has been free for commercial use including every former Club plugin since version 3.13, so SplitText, DrawSVG, MorphSVG and the rest load from the same CDN with no licence to buy.

Keep this file to the library tags. Anything else you put in a core theme file is removed when the theme updates.

A Custom Liquid section, for the animation

This is the right home for the effect itself. In the theme editor, Add section → Custom Liquid (or Add block → Custom Liquid inside an existing section), then paste your HTML, CSS and <script> into the field.

Custom Liquid content is stored with the template rather than in the theme’s code, so a theme update leaves it alone. It is also server-rendered, so it costs nothing on the client beyond the code you actually wrote.

A section file’s {% javascript %} tag, for reusable sections

If you’re building a proper section file, bundle its script with the {% javascript %} tag:

{% javascript %}
  // scoped to this section file
{% endjavascript %}

Two things to know. Shopify injects the bundle once per section file, not once per instance, so anything instance-specific has to come from data- attributes on the markup rather than from variables in the script. And there is a long-standing quirk where the bundled script doesn’t always execute on a section the merchant has just added in the editor, which makes the next section of this guide more important, not less.

The part most tutorials miss: the editor re-renders your section

Here is the failure that generates most “my animation works on the live site but not in the customizer” support threads.

When a merchant changes a section’s settings in the theme editor, Shopify does not reload the page. It fetches that section’s HTML again and swaps it into the DOM. Every element your script measured, hid, or attached a listener to is gone, replaced by a fresh copy with none of your setup on it.

Code written like this runs exactly once, on first page load:

document.addEventListener('DOMContentLoaded', function () {
  // never runs again after the editor re-renders the section
});

The result: the merchant edits a heading, the animation stops, and the cards stay stuck at opacity: 0 because the pre-hide ran on elements that no longer exist. Worse, the ScrollTriggers you created keep measuring detached nodes on every scroll, so a merchant who nudges settings twenty times is dragging twenty dead animation instances around.

Shopify dispatches events for exactly this. The two that matter:

EventFires whenDetail
shopify:section:loada section is added or re-rendered{ sectionId }
shopify:section:unloada section is deleted, or about to be re-rendered{ sectionId }

They fire on the section’s wrapper element (<div id="shopify-section-…">) and bubble, so you can listen once on document. Shopify’s guidance is explicit: re-run whatever the section needs on load, and on unload “clean up any event listeners, variables, etc., so that nothing breaks when the page is interacted with and no memory leaks occur”.

There are more of them. shopify:section:select, :deselect and :reorder, plus shopify:block:select / :deselect with a blockId in the detail, are useful for playing an animation when the merchant clicks a block in the sidebar. For getting an effect to survive editing, load and unload are the pair you need.

GSAP has a matching tool: gsap.context() records every tween, ScrollTrigger and inline style created inside it, and revert() undoes all of it in one call. Pair one context per section instance and the lifecycle problem is solved.

Worked example: staggered collection grid reveal

What you’ll build

A product grid where cards fade and lift into view in a wave that follows the layout rather than document order, driven by GSAP’s grid-aware stagger. It re-initialises cleanly when a merchant edits the section, re-measures once lazy-loaded product images have settled, and falls back to a plain visible grid under reduced motion.

Live, interactive preview. Scroll inside the frame to drive the reveal. Get the full code →

Step 1: the markup

This version loops over a collection, so it belongs in a section file. Create sections/animated-collection.liquid:

<div class="gsap-grid" data-gsap-grid>
  {% for product in collections[section.settings.collection].products limit: section.settings.count %}
    <a class="gsap-card" data-gsap-item href="{{ product.url }}">
      {% if product.featured_image %}
        <img
          src="{{ product.featured_image | image_url: width: 600 }}"
          alt="{{ product.featured_image.alt | escape }}"
          width="600"
          height="{{ 600 | divided_by: product.featured_image.aspect_ratio | round }}"
          loading="lazy">
      {% endif %}
      <span class="gsap-card__title">{{ product.title }}</span>
      <span class="gsap-card__price">{{ product.price | money }}</span>
    </a>
  {% endfor %}
</div>

{% schema %}
{
  "name": "Animated collection",
  "settings": [
    { "type": "collection", "id": "collection", "label": "Collection" },
    { "type": "range", "id": "count", "min": 3, "max": 24, "step": 3, "default": 6, "label": "Products" }
  ],
  "presets": [{ "name": "Animated collection" }]
}
{% endschema %}

Two attributes carry the whole contract: data-gsap-grid on the container, data-gsap-item on each card. The script never looks for a theme class, so a theme update that renames CSS can’t break it.

To try the effect without creating a section file, paste the same markup with hardcoded cards into a Custom Liquid block. Everything below works either way.

Step 2: the CSS

.gsap-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.5rem;
}

.gsap-card {
  display: block;
  text-decoration: none;
  color: inherit;
  will-change: transform, opacity;
}

.gsap-card img {
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  border-radius: 8px;
}

@media (max-width: 749px) {
  .gsap-grid { grid-template-columns: repeat(2, 1fr); }
}

The column count lives only here. GSAP reads the rendered grid at runtime, so the number is never repeated in JavaScript.

Note what is not here: an opacity: 0 start state. Hiding cards in CSS means a visitor whose JavaScript fails, or who prefers reduced motion, sees an empty collection page. The hidden state is set from JavaScript, after GSAP is confirmed loaded.

Step 3: the script

(function () {
  if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') return;
  gsap.registerPlugin(ScrollTrigger);

  var reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
  var contexts = new WeakMap();

  function initGrid(grid) {
    if (contexts.has(grid)) destroyGrid(grid);
    if (reduced.matches) return;

    var items = grid.querySelectorAll('[data-gsap-item]');
    if (!items.length) return;

    // Everything created in here is recorded, and reverted together later
    var ctx = gsap.context(function () {
      gsap.set(items, { opacity: 0, y: 40 });

      ScrollTrigger.create({
        trigger: grid,
        start: 'top 85%',
        once: true,
        onEnter: function () {
          gsap.to(items, {
            opacity: 1,
            y: 0,
            duration: 0.8,
            ease: 'power3.out',
            stagger: { each: 0.07, grid: 'auto', from: 'start' }
          });
        }
      });
    }, grid);

    contexts.set(grid, ctx);
    refreshWhenImagesSettle(grid);
  }

  function destroyGrid(grid) {
    var ctx = contexts.get(grid);
    if (!ctx) return;
    ctx.revert(); // kills tweens and ScrollTriggers, restores inline styles
    contexts.delete(grid);
  }

  function eachGrid(root, fn) {
    if (!root || !root.querySelectorAll) return;
    Array.prototype.forEach.call(root.querySelectorAll('[data-gsap-grid]'), fn);
  }

  // Lazy-loaded product images arrive after ScrollTrigger measured the page
  function refreshWhenImagesSettle(root) {
    var pending = Array.prototype.filter.call(
      root.querySelectorAll('img'),
      function (img) { return !img.complete; }
    );
    if (!pending.length) return;

    var left = pending.length;
    pending.forEach(function (img) {
      var done = function () { if (--left === 0) ScrollTrigger.refresh(); };
      img.addEventListener('load', done, { once: true });
      img.addEventListener('error', done, { once: true });
    });
  }

  function initAll() { eachGrid(document, initGrid); }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAll);
  } else {
    initAll();
  }

  // Theme editor: a section was added or re-rendered
  document.addEventListener('shopify:section:load', function (event) {
    eachGrid(event.target, initGrid);
  });

  // Theme editor: a section is going away, or about to be replaced
  document.addEventListener('shopify:section:unload', function (event) {
    eachGrid(event.target, destroyGrid);
  });

  // Someone flips the OS setting mid-visit
  reduced.addEventListener('change', function () {
    eachGrid(document, reduced.matches ? destroyGrid : initGrid);
  });
})();

Put this in the section file’s {% javascript %} tag, or in a <script> inside the Custom Liquid block.

What the non-obvious parts are doing:

  • gsap.context(fn, grid) scopes selectors to that grid and records everything created inside, so revert() cleans up one section instance without touching the others on the page.
  • initGrid destroys first. shopify:section:load can fire on a section that already has a live context, and re-entering without cleaning up is how you end up with stacked ScrollTriggers.
  • WeakMap keyed by element means a section removed from the DOM takes its entry with it, no bookkeeping list to leak.
  • refreshWhenImagesSettle matters more on Shopify than on most platforms. Collection images are lazy-loaded and unsized until they arrive, so ScrollTrigger’s start positions are computed against a page that is about to get taller. One refresh() after the last image resolves fixes reveals that fire early or never.
  • The reduced-motion branch returns before gsap.set, so those visitors get the full grid immediately rather than a grid that never un-hides.

Step 4: handle filtered and paginated collections

Collection filters, sorting and “load more” don’t go through the theme editor. They use the Section Rendering API, which fetches new markup and swaps it in without firing any shopify: event. Whatever handles that fetch needs to re-init afterwards:

// after your fetch replaces the grid markup
eachGrid(document.getElementById('shopify-section-' + sectionId), initGrid);

If the swap happens inside theme code you’d rather not edit, watch for it instead:

var observer = new MutationObserver(function () {
  eachGrid(document, function (grid) {
    if (!contexts.has(grid)) initGrid(grid);
  });
});
observer.observe(document.body, { childList: true, subtree: true });

The contexts.has check keeps the observer idempotent: grids already running are left alone, only genuinely new markup gets initialised.

That’s it

The grid cascades in as it scrolls into view, keeps working while a merchant edits the section, and survives filter and pagination swaps. Raise each for a slower wave, or set from: 'center' to ripple outward from the middle of the grid instead of the top-left corner.


Other effects that suit a store

Live, interactive preview. Get the full code →
  • Infinite marquee for an announcement bar, a shipping-threshold ticker, or a scrolling strip of stockist logos. The loop is seamless, which the CSS @keyframes version usually isn’t.
  • Split text reveal for a homepage or campaign headline that assembles line by line.
  • Count-up stats for the social-proof band: orders shipped, reviews, years trading, counting up as it enters view.
  • Hover image trail for a lookbook or editorial collection page, where the pointer drags a trail of product shots behind it.

Every one of them needs the same lifecycle wrapper as the grid above. The initGrid / destroyGrid / shopify:section:load skeleton is reusable as-is; only the contents of the gsap.context callback change.

What not to do

Don’t put animation code in the checkout. checkout.liquid, Additional Scripts and Shopify Scripts have all been retired by Checkout Extensibility, and non-Plus stores are auto-upgraded off them on 26 August 2026. Checkout customisation now goes through Checkout UI Extensions, Web Pixels and Shopify Functions. Storefront pages are where custom animation belongs.

Don’t hide above-the-fold content behind a script. Pre-hiding a hero with opacity: 0 and revealing it from JavaScript pushes out Largest Contentful Paint on the exact page where conversion is measured. Animate what’s below the fold; let the hero paint.

Don’t animate layout properties. width, height, top and left force the browser to re-run layout every frame. Transforms and opacity stay on the compositor. Why GSAP animations get janky goes through the full list.

Don’t stack effects on the product page. The PDP is the page that makes money. One considered reveal is worth more than four competing ones fighting for the same scroll.


Want more control?

The full Stagger Grid Reveal effect includes features this basic version doesn’t have:

  • Eight stagger directions including diagonal and centre-outward waves, plus edges and random patterns
  • Seven animation styles covering fade, scale, flip, slide, blur, rotate, and bounce, each with a tuned from-state
  • ScrollTrigger batch processing that groups items entering the viewport together, which matters on a collection page with 48 cards
  • Interactive hover tilt with 3D perspective on pointer devices
  • Configurable columns, gap, and item dimensions from data attributes alone
  • Responsive breakpoint handling with automatic recalculation when the layout changes

View the full effect with all options →