Shopify Custom Scroll-Trigger Animation (Copy, Paste, Customize)

Micro-animations make your Shopify store feel premium: product tiles that rise into view, badges that pop, hero copy that fades in gently. Done well, they guide attention and improve comprehension. Done poorly, they tank performance and annoy users.

Shopify Custom Scrol-Trigger
Shopify Custom Scrol-Trigger

In this tutorial you’ll add scroll-triggered animations to Shopify with a tiny, framework-free system built on modern browser APIs. It uses IntersectionObserver to watch elements as they enter the viewport and applies CSS classes to animate them. You’ll get:

  • Effects: fade-up, slide-left/right, scale-in, blur-in
  • Controls via data-attributes (duration, delay, easing, offset, repeat/once)
  • Prefers-reduced-motion support
  • One file you can paste into a Custom HTML block

This is an example implementation. Paste it, test it, then tailor the effects, timings, and classes to match your brand.

Where to paste this in Shopify

  1. Online Store → Themes → Customize
  2. Open the page/template you want (Home, Collection, Blog, etc.)
  3. Add section → Custom HTML (or Custom Liquid)
  4. Paste the entire snippet below
  5. Save & preview on desktop and mobile

Copy-Paste Example — Custom Scroll-Trigger System

Paste everything from <!DOCTYPE html> to </html>.

DESIGNED FOR REFERENCE ONLY
<body>


  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Shopify Custom Scroll Trigger Animation</title>
  <style>
    /* ====== THEME TOKENS (tweak for your brand) ====== */
    :root{
      --st-text: #e8ecf1;
      --st-bg: #0f1115;
      --st-card: #151924;
      --st-accent: #00d084;
      --st-radius: 14px;
      --st-gap: 18px;
    }
    body{ margin:0; font-family: Arial, sans-serif; background:var(--st-bg); color:var(--st-text); }

    /* ====== DEMO LAYOUT (safe to remove/replace) ====== */
    .container{ max-width:1200px; margin:0 auto; padding:40px 20px; }
    .hero{
      min-height:55vh; display:grid; place-items:center; text-align:center; padding:40px 0;
    }
    .hero h1{ font-size:clamp(28px, 6vw, 56px); margin:0 0 10px; }
    .hero p{ font-size:clamp(14px, 2.5vw, 18px); opacity:.9; }
    .grid{
      display:grid; grid-template-columns: repeat(3, 1fr); gap:var(--st-gap);
    }
    .card{
      background:var(--st-card); border-radius:var(--st-radius); overflow:hidden;
      display:flex; flex-direction:column; min-height:320px;
      box-shadow: 0 10px 30px rgba(0,0,0,.18);
    }
    .card img{ width:100%; height:180px; object-fit:cover; display:block; }
    .card .pad{ padding:14px; }
    .card h3{ margin:0 0 6px; font-size:18px; }
    .card p{ margin:0; font-size:14px; opacity:.9; }
    .cta{
      margin-top:22px; display:inline-block; background:var(--st-accent); color:#0f1115;
      padding:10px 14px; border-radius:10px; font-weight:800; text-decoration:none;
    }
    @media (max-width:900px){ .grid{ grid-template-columns: repeat(2, 1fr);} }
    @media (max-width:600px){ .grid{ grid-template-columns: 1fr; } }

    /* ====== SCROLL-TRIGGER CORE (effects + states) ====== */

    /* Start state applied by [data-st] (before entering view) */
    [data-st]{
      opacity: 0;
      transform: translate3d(0, 12px, 0) scale(0.98);
      filter: blur(0px);
      will-change: transform, opacity, filter;
      transition-property: opacity, transform, filter;
      transition-timing-function: cubic-bezier(.2,.65,.2,1);
      transition-duration: 600ms;
      transition-delay: 0ms;
    }

    /* In-view final state (added by JS) */
    .st-in{
      opacity: 1;
      transform: none;
      filter: none;
    }

    /* Effect presets (override start transform) */
    .st-fade-up{ transform: translate3d(0, 18px, 0); }
    .st-slide-left{ transform: translate3d(28px, 0, 0); }
    .st-slide-right{ transform: translate3d(-28px, 0, 0); }
    .st-scale-in{ transform: scale(.9); }
    .st-blur-in{ filter: blur(6px); }

    /* Motion safety: respect reduced-motion */
    @media (prefers-reduced-motion: reduce){
      [data-st]{
        transition-duration: 1ms !important;
        transform: none !important;
        filter: none !important;
      }
    }
  </style>



  <section class="hero container">
    <div>
      <h1 data-st class="st-fade-up" data-st-delay="80">Make Your Store Move</h1>
      <p data-st class="st-fade-up" data-st-delay="180">Lightweight scroll-triggered animations that respect performance and accessibility.</p>
      <a href="/collections/all" class="cta" data-st data-st-delay="320">Shop new arrivals</a>
    </div>
  </section>

  <section class="container">
    <div class="grid">
      <!-- CARD 1 -->
      <article class="card" data-st data-st-duration="700" data-st-offset="0.18">
        <img src="https://picsum.photos/id/1021/800/600" alt="Merino Wool Crew">
        <div class="pad">
          <h3>Merino Wool Crew</h3>
          <p>Temperature-regulating knit for all-season comfort.</p>
        </div>
      </article>

      <!-- CARD 2 (with delay) -->
      <article class="card" data-st data-st-delay="120" data-st-duration="650">
        <img src="https://picsum.photos/id/103/800/600" alt="Packable Puffer">
        <div class="pad">
          <h3>Packable Puffer</h3>
          <p>Warmth without the bulk — packs into itself.</p>
        </div>
      </article>

      <!-- CARD 3 (blur-in) -->
      <article class="card" data-st data-st-delay="200" data-st-duration="700">
        <img src="https://picsum.photos/id/1044/800/600" alt="All-weather Shell">
        <div class="pad">
          <h3>All-weather Shell</h3>
          <p>Windproof and waterproof for unpredictable days.</p>
        </div>
      </article>

      <!-- CARD 4 (slide-right) -->
      <article class="card" data-st data-st-offset="0.15">
        <img src="https://picsum.photos/id/1005/800/600" alt="Everyday Tote">
        <div class="pad">
          <h3>Everyday Tote</h3>
          <p>Heavyweight canvas with reinforced handles.</p>
        </div>
      </article>

      <!-- CARD 5 -->
      <article class="card" data-st data-st-delay="120">
        <img src="https://picsum.photos/id/1011/800/600" alt="Studio Hoodie">
        <div class="pad">
          <h3>Studio Hoodie</h3>
          <p>Clean silhouette, cozy interior, everyday essential.</p>
        </div>
      </article>

      <!-- CARD 6 -->
      <article class="card" data-st data-st-delay="220">
        <img src="https://picsum.photos/id/1000/800/600" alt="Runner Sneaker">
        <div class="pad">
          <h3>Runner Sneaker</h3>
          <p>Cushioned ride with minimalist profile.</p>
        </div>
      </article>
    </div>
  </section>

  <script>
    (function(){
      // CONFIG DEFAULTS
      const DEFAULTS = {
        rootMargin: '0px 0px -10% 0px',  // start a bit before element fully enters
        threshold: 0.12,                 // default intersection threshold
        once: true                       // animate once by default
      };

      const supportsIO = 'IntersectionObserver' in window;
      const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      // Helper: convert ms string/number
      const toMs = (v, fallback) => {
        const n = Number(v);
        return Number.isFinite(n) ? Math.max(0, n) : fallback;
      };

      function applyTiming(el){
        const dur = toMs(el.getAttribute('data-st-duration'), 600);
        const del = toMs(el.getAttribute('data-st-delay'), 0);
        const easing = el.getAttribute('data-st-ease') || 'cubic-bezier(.2,.65,.2,1)';
        el.style.transitionDuration = dur + 'ms';
        el.style.transitionDelay = del + 'ms';
        el.style.transitionTimingFunction = easing;
      }

      function reveal(el){
        applyTiming(el);
        el.classList.add('st-in');
      }

      // If reduced motion, reveal everything immediately
      if(prefersReduced || !supportsIO){
        document.querySelectorAll('[data-st]').forEach(el => {
          el.classList.add('st-in');
        });
        return;
      }

      // Build one observer for the page
      const io = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
          const el = entry.target;
          const once = (el.getAttribute('data-st-repeat') !== 'true'); // default once=true
          if(entry.isIntersecting){
            reveal(el);
            if(once) io.unobserve(el);
          } else if(!once){
            el.classList.remove('st-in'); // allow re-animate when it leaves and re-enters
          }
        });
      }, {
        root: null,
        rootMargin: DEFAULTS.rootMargin,
        threshold: DEFAULTS.threshold
      });

      // Observe all elements with [data-st]
      document.querySelectorAll('[data-st]').forEach(el => {
        // Per-element threshold/offset (data-st-offset is fraction 0..1)
        const offset = parseFloat(el.getAttribute('data-st-offset'));
        if(!Number.isNaN(offset)){
          // Create a custom observer per-element if offset provided
          const custom = new IntersectionObserver((entries)=>{
            entries.forEach(entry=>{
              const once = (el.getAttribute('data-st-repeat') !== 'true');
              if(entry.intersectionRatio >= (offset || DEFAULTS.threshold)){
                reveal(el);
                if(once) custom.unobserve(el);
              } else if(!once){
                el.classList.remove('st-in');
              }
            });
          }, { threshold: [offset] });
          custom.observe(el);
        } else {
          io.observe(el);
        }
      });
    })();
  </script>

</body>

How it works (quick)

  • HTML: Add data-st to any element you want to animate. Add one of the preset classes: st-fade-up, st-slide-left, st-slide-right, st-scale-in, st-blur-in.
  • CSS: Elements start in an offset/hidden state. When .st-in is added, they transition to their natural position/opacity.
  • JS: An IntersectionObserver watches for elements entering the viewport. When they intersect the threshold (customizable), the script adds .st-in. It supports once (default) or repeat (data-st-repeat="true"). If the browser doesn’t support IntersectionObserver or if the user prefers reduced motion, elements reveal instantly (no animation).

What you can customize (fast)

  • Effect type — just swap the class:st-fade-up, st-slide-left, st-slide-right, st-scale-in, st-blur-in.
  • Duration — data-st-duration="700" (milliseconds).
  • Delay — data-st-delay="120" to stagger siblings.
  • Easing — data-st-ease="ease-out" or any cubic-bezier.
  • Threshold/Offset — data-st-offset="0.2" to wait until 20% is visible.
  • Repeat — data-st-repeat="true" to animate every time it re-enters.
  • Global feel — adjust the base transforms in the CSS for stronger or subtler motion.

Best practices

  • Animate with purpose: Use motion to reveal content hierarchy (headline first, then body, then CTAs).
  • Keep durations short (500–800ms) and stagger lightly (60–200ms) for a premium feel.
  • Minimize layout shift: Avoid animating layout-affecting properties (like height). Use transforms and opacity.
  • Respect accessibility: The snippet honors prefers-reduced-motion and reveals content immediately.
  • Optimize images: Heavy assets can ruin smoothness; compress and lazy-load where possible.
  • Don’t overdo it: Too many animations can slow down perception and feel noisy.

Troubleshooting

  • Nothing animates → Ensure you pasted the whole snippet; check browser support (IO is widely supported). On very old browsers, our fallback reveals content instantly (working by design).
  • Animations fire too late/early → Adjust data-st-offset (e.g., 0.08 earlier, 0.3 later).
  • Wants to re-animate on back/forward nav → Add data-st-repeat="true" on the elements you want to re-trigger.
  • Motion feels heavy → Reduce transforms and durations; keep delays short.
  • Text appears blurry during motion → It’s usually fine with translate3d; if it persists, tone down the transform or remove blur on smaller text.

Final notes

This custom scroll-trigger is tiny, fast, and flexible. Because it’s pure HTML/CSS/JS, it drops into Shopify without Liquid or apps and won’t throw syntax errors in the editor. Treat it as an example: rename classes, create new presets (e.g., rotate-in, clip-reveal), and wire it to your component classes across the site. When you’re ready for full editor controls, you can port the same logic into a theme section with schema.