Shopify Fade-In on Scroll Snippet (Copy, Paste, Customize)

A subtle fade-in on scroll helps guide attention and makes long pages feel intentional. The pattern here uses modern browser APIs (IntersectionObserver) and GPU-friendly CSS transforms to keep things smooth. The code is framework-free, tiny, and respects users who prefer reduced motion. You’ll copy and paste one self-contained block, update a few classes, and you’re done.

Fade-In on Scroll Snippet
Fade-In on Scroll Snippet

Where to paste this in Shopify

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

This is an example implementation you can adapt anywhere. If later you want editor controls, convert it into a Liquid section and expose settings in the schema; the HTML/CSS/JS stays the same.

Copy-Paste Example — Fade-In on Scroll

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 Fade-In on Scroll</title>
  <style>
    :root{
      /* Brand + motion tokens */
      --fi-bg: #0f1115;
      --fi-fg: #e8ecf1;
      --fi-muted: #aeb8c6;
      --fi-accent: #00d084;

      --fi-duration: 680ms;         /* default animation duration */
      --fi-distance: 22px;          /* default translateY start */
      --fi-stagger-step: 90ms;      /* default stagger gap */
    }

    html,body{ margin:0; background:var(--fi-bg); color:var(--fi-fg); font-family: Arial, sans-serif; }

    /* Demo container (safe to change/remove) */
    .fi-container{ max-width: 1200px; margin:0 auto; padding: 40px 18px 90px; }

    /* Section layout for demo */
    .fi-hero{
      min-height: 56vh; display:grid; place-items:center; text-align:center; padding: 60px 16px 30px;
      background: linear-gradient(180deg, #111624, #0f1115);
    }
    .fi-hero h1{ margin:0 0 10px; font-size: clamp(28px, 5.6vw, 56px); line-height: 1.06; }
    .fi-hero p{ margin:0 auto; max-width: 64ch; color: var(--fi-muted); }

    .fi-grid{
      display:grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 26px;
    }
    @media (max-width: 980px){ .fi-grid{ grid-template-columns: repeat(2, 1fr); } }
    @media (max-width: 600px){ .fi-grid{ grid-template-columns: 1fr; } }

    .fi-card{
      background:#151924; border:1px solid #1f2636; border-radius:14px; overflow:hidden;
      box-shadow: 0 10px 30px rgba(0,0,0,.22);
    }
    .fi-card img{ width:100%; height: 200px; object-fit: cover; display:block; }
    .fi-card .pad{ padding: 14px; }
    .fi-card h3{ margin: 6px 0 4px; font-size: 18px; }
    .fi-card p{ margin:0; color: var(--fi-muted); font-size: 14px; }

    /* ========= Fade-In System =========
       Elements marked with [data-fi] start hidden and offset.
       JS adds .fi-in when they intersect the viewport. */

    [data-fi]{
      opacity: 0;
      transform: translate3d(0, var(--fi-distance), 0);
      will-change: opacity, transform;
      transition-property: opacity, transform;
      transition-duration: var(--fi-duration);
      transition-timing-function: cubic-bezier(.2,.65,.2,1);
      transition-delay: 0ms;
    }

    /* Final state when in view */
    .fi-in{
      opacity: 1;
      transform: none;
    }

    /* Optional presets: adjust starting transform per-effect */
    .fi-up    { transform: translate3d(0, 22px, 0); }
    .fi-down  { transform: translate3d(0,-22px, 0); }
    .fi-left  { transform: translate3d(22px, 0, 0); }
    .fi-right { transform: translate3d(-22px,0, 0); }
    .fi-scale { transform: scale(.96); }

    /* Reduced motion: reveal immediately with no animation */
    @media (prefers-reduced-motion: reduce){
      [data-fi]{
        transition-duration: 1ms !important;
        transform: none !important;
      }
    }
  </style>



  <!-- Hero -->
  <section class="fi-hero">
    <div>
      <h1 data-fi class="fi-up" data-fi-delay="80">Fade-In on Scroll for Shopify</h1>
      <p data-fi class="fi-up" data-fi-delay="160">Lightweight, modern, and accessible. Paste once, reuse everywhere. Works for headings, cards, images, and buttons.</p>
    </div>
  </section>

  <!-- Content area with staggered cards -->
  <section class="fi-container" data-fi-stagger=".fi-card" data-fi-stagger-step="90">
    <div class="fi-grid">
      <article class="fi-card" data-fi>
        <img src="https://picsum.photos/id/1000/1200/800" alt="Product 1">
        <div class="pad">
          <h3>Merino Crew</h3>
          <p>All-season knit with soft hand feel.</p>
        </div>
      </article>

      <article class="fi-card" data-fi>
        <img src="https://picsum.photos/id/1005/1200/800" alt="Product 2">
        <div class="pad">
          <h3>Packable Puffer</h3>
          <p>Lightweight warmth that compresses small.</p>
        </div>
      </article>

      <article class="fi-card" data-fi>
        <img src="https://picsum.photos/id/1020/1200/800" alt="Product 3">
        <div class="pad">
          <h3>Minimal Runner</h3>
          <p>Cushioned ride, low-profile silhouette.</p>
        </div>
      </article>
    </div>
  </section>

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

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

      // Apply per-element timing from data attributes
      function applyTiming(el){
        const dur = parseInt(el.getAttribute('data-fi-duration') || '', 10);
        const del = parseInt(el.getAttribute('data-fi-delay') || '', 10);
        const ease = el.getAttribute('data-fi-ease');
        if(Number.isFinite(dur)) el.style.transitionDuration = dur + 'ms';
        if(Number.isFinite(del)) el.style.transitionDelay = del + 'ms';
        if(ease) el.style.transitionTimingFunction = ease;
      }

      // Container stagger support
      function applyStagger(container){
        const sel = container.getAttribute('data-fi-stagger');
        if(!sel) return;
        const step = parseInt(container.getAttribute('data-fi-stagger-step') || '90', 10);
        const items = container.querySelectorAll(sel + '[data-fi], ' + sel + ' [data-fi]');
        items.forEach((el, i) => {
          const existing = parseInt(el.getAttribute('data-fi-delay') || '0', 10);
          el.setAttribute('data-fi-delay', String(existing + i * step));
        });
      }

      // Prepare container staggers first
      document.querySelectorAll('[data-fi-stagger]').forEach(applyStagger);

      // Build observers
      const defaultThreshold = 0.12;

      function observe(el){
        applyTiming(el);
        const repeat = el.getAttribute('data-fi-repeat') === 'true'; // default once
        const offset = parseFloat(el.getAttribute('data-fi-offset') || '');
        if(Number.isFinite(offset)){
          // Custom threshold for this element
          const custom = new IntersectionObserver((entries)=>{
            entries.forEach(entry=>{
              if(entry.intersectionRatio >= offset){
                el.classList.add('fi-in');
                if(!repeat) custom.unobserve(el);
              }else if(repeat){
                el.classList.remove('fi-in');
              }
            });
          }, { threshold: [offset] });
          custom.observe(el);
          return;
        }

        io.observe(el);
      }

      const io = new IntersectionObserver((entries)=>{
        entries.forEach(entry=>{
          const el = entry.target;
          const repeat = el.getAttribute('data-fi-repeat') === 'true';
          if(entry.isIntersecting){
            el.classList.add('fi-in');
            if(!repeat) io.unobserve(el);
          } else if(repeat){
            el.classList.remove('fi-in');
          }
        });
      }, { threshold: defaultThreshold, rootMargin: '0px 0px -8% 0px' });

      document.querySelectorAll('[data-fi]').forEach(observe);
    })();
  </script>

</body>

How it works

  • Mark any element you want to animate with [data-fi].
  • Optional effect presets: add fi-up, fi-down, fi-left, fi-right, or fi-scale to adjust the starting transform.
  • When the element intersects the viewport, JS adds .fi-in so it transitions to full opacity and its natural position.
  • The system honors prefers-reduced-motion and reveals content instantly when users opt out of animation or when IntersectionObserver isn’t available.

Quick customization

  • Per element:data-fi-delay="200" to stagger without a container.data-fi-duration="800" to lengthen a specific item.data-fi-ease="ease-out" to customize easing.data-fi-offset="0.25" to wait until 25% of the element is visible.data-fi-repeat="true" to animate again when it re-enters view.
  • Per container:data-fi-stagger=".fi-card" and data-fi-stagger-step="90" to cascade children automatically.
  • Global feel:Edit --fi-duration, --fi-distance, or preset transforms for stronger/subtler motion.

Performance and accessibility notes

  • Animating transforms and opacity is GPU-friendly and avoids layout thrash.
  • Keep durations 500–800 ms and delays 60–200 ms for a premium feel without sluggishness.
  • Large images should be compressed and lazy-loaded to keep scroll smooth.
  • Don’t animate everything; use motion to clarify hierarchy.
  • The snippet respects reduced motion by revealing instantly.

Turning this into a Liquid section (optional)

This post uses a plain snippet so you can paste and test quickly. To make it a proper Section with settings, create /sections/fade-in.liquid, move the markup inside, and add a JSON schema to expose copy fields, toggles (repeat, offsets), and stagger controls. The CSS/JS stays the same; only content sources change.