Shopify Sticky Footer on Scroll (Copy, Paste, Customize)

Sticky footers are great for promotions, free-shipping thresholds, signups, or “Add to Cart” nudges on long pages. This snippet adds a tasteful, mobile-first footer that slides in after the visitor scrolls, optionally only when they scroll up. It’s framework-free, tiny, and respects users who prefer reduced motion. You’ll paste one self-contained block, tweak a few data-attributes, and ship.

Shopify Sticky Footer
Shopify Sticky Footer

Where to paste this in Shopify

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

If you want editor controls later, convert it to a Liquid section and expose settings in the schema; the HTML/CSS/JS stays the same.

Copy-Paste Example — Sticky Footer 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">

  <!-- Optional SEO tags for this article page -->
  <title>Shopify Sticky Footer on Scroll — Lightweight HTML/CSS/JS (No Apps)</title>
  <meta name="title" content="Shopify Sticky Footer on Scroll — Lightweight HTML/CSS/JS (No Apps)">
  <meta name="description" content="Add a clean sticky footer bar that appears on scroll for Shopify. Tiny, dependency-free HTML/CSS/JS with show-on-scroll-up, thresholds, dismiss persistence, and reduced-motion support. No apps, Liquid-ready.">

  <style>
    :root{
      /* Brand + motion tokens (tune to your theme) */
      --sf-bg: #0f1115;
      --sf-fg: #e8ecf1;
      --sf-muted:#aeb8c6;
      --sf-accent:#00d084;

      --sf-duration: 420ms;
      --sf-ease: cubic-bezier(.2,.65,.2,1);
      --sf-shadow: 0 10px 30px rgba(0,0,0,.32);
      --sf-border: #1f2636;
      --sf-z: 9999;
    }

    html,body{ margin:0; background:#0f1115; color:var(--sf-fg); font-family: Arial, sans-serif; }
    .demo-content{ min-height:160vh; padding: 24px 16px 120px; max-width: 1100px; margin: 0 auto; }
    .demo-content h1{ font-size: clamp(28px, 5.5vw, 54px); margin: 18px 0 6px; }
    .demo-content p{ color: var(--sf-muted); max-width: 70ch; }

    /* ===== Sticky Footer System ===== */
    .sf-wrap{
      position: fixed;
      left: 0; right: 0; bottom: 0;
      z-index: var(--sf-z);
      transform: translateY(100%);
      transition: transform var(--sf-duration) var(--sf-ease);
      will-change: transform;
      /* iOS safe area */
      padding-bottom: env(safe-area-inset-bottom);
    }
    .sf-in{ transform: translateY(0); }

    .sf-bar{
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 10px;
      align-items: center;
      background: var(--sf-bg);
      color: var(--sf-fg);
      border-top: 1px solid var(--sf-border);
      box-shadow: var(--sf-shadow);
      padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
    }

    .sf-lead{
      line-height:1.25;
      font-size: 15px;
    }
    .sf-lead strong{ color: var(--sf-fg); }
    .sf-lead span{ color: var(--sf-muted); }

    .sf-actions{
      display:flex; gap: 10px; align-items:center; flex-wrap: wrap;
    }
    .sf-btn{
      appearance: none; border:1px solid var(--sf-border); background: transparent; color: var(--sf-fg);
      padding: 10px 14px; border-radius: 10px; font-size: 14px; cursor: pointer;
    }
    .sf-btn--primary{
      background: var(--sf-accent); color:#071410; border:1px solid transparent;
      font-weight: 700;
    }
    .sf-dismiss{
      appearance:none; background: transparent; border: none; color: var(--sf-muted);
      cursor:pointer; padding: 8px; border-radius: 8px; line-height: 0;
    }
    .sf-dismiss:focus-visible, .sf-btn:focus-visible{
      outline: 2px solid var(--fi-accent, var(--sf-accent)); outline-offset: 2px;
    }

    /* Reduced motion: no slide animation */
    @media (prefers-reduced-motion: reduce){
      .sf-wrap{ transition-duration: 1ms !important; }
    }

    /* Example: avoid overlapping theme drawers if any (optional) */
    .sf-avoid-drawer [aria-hidden="false"][data-drawer]{ margin-bottom: 64px; }
  </style>



  <!-- Demo page content (safe to remove) -->
  <main class="demo-content">
    <h1>Sticky Footer on Scroll (Demo)</h1>
    <p>This bar appears after you scroll a bit, or when you scroll up—tune behavior with data attributes.</p>
    <p>Use it for promotions, free shipping thresholds, signups, or a quick “Add to cart”.</p>
  </main>

  <!-- Sticky Footer: paste once and reuse everywhere -->
  <div class="sf-wrap" role="region" aria-label="Site offer" data-sf-after="280" show after n px scrolled>
    data-sf-up-only="true"         <!-- Only show when user scrolls up (true/false) -->
    data-sf-hide-near="footer"     <!-- CSS selector to hide when near real footer -->
    data-sf-near-margin="320"      <!-- Distance in px from selector before hiding -->
    data-sf-storage="promo-q4"     <!-- Dismiss persistence key in localStorage -->
    data-sf-session="false"        <!-- true = sessionStorage, false = localStorage -->
  >
    <div class="sf-bar" aria-live="polite">
      <div class="sf-lead">
        <strong>Free shipping over $75</strong> — <span>Ends Sunday. Use code <b>SHIPFAST</b>.</span>
      </div>
      <div class="sf-actions">
        <a class="sf-btn sf-btn--primary" href="/collections/all" aria-label="Shop now">Shop Now</a>
        <button class="sf-btn" onclick="document.querySelector('html,body'); window.scrollTo({top:0, behavior:'smooth'});" aria-label="Back to top">Top</button>
        <button class="sf-dismiss" type="button" aria-label="Dismiss offer" title="Dismiss">
          ✕
        </button>
      </div>
    </div>
  </div>

  <script>
    (function(){
      const wrap = document.querySelector('.sf-wrap');
      if(!wrap) return;

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

      // Config from data-attributes
      const after = parseInt(wrap.getAttribute('data-sf-after') || '200', 10);
      const upOnly = (wrap.getAttribute('data-sf-up-only') || 'false') === 'true';
      const nearSel = wrap.getAttribute('data-sf-hide-near') || '';
      const nearMargin = parseInt(wrap.getAttribute('data-sf-near-margin') || '260', 10);
      const storageKey = wrap.getAttribute('data-sf-storage') || '';
      const sessionMode = (wrap.getAttribute('data-sf-session') || 'false') === 'true';

      // Storage helpers
      const store = sessionMode ? window.sessionStorage : window.localStorage;
      const isDismissed = () => storageKey ? store.getItem('sf-dismiss:' + storageKey) === '1' : false;
      const setDismissed = () => { if(storageKey) try{ store.setItem('sf-dismiss:' + storageKey, '1'); }catch(e){} };

      // Dismiss button
      const dismissBtn = wrap.querySelector('.sf-dismiss');
      if(dismissBtn){
        dismissBtn.addEventListener('click', () => {
          wrap.classList.remove('sf-in');
          setDismissed();
        });
      }

      // If previously dismissed, keep hidden
      if(isDismissed()){
        wrap.style.display = 'none';
        return;
      }

      // Visibility state
      let lastY = window.scrollY || window.pageYOffset;
      let shown = false;

      function show(){
        if(!shown){
          wrap.classList.add('sf-in');
          shown = true;
        }
      }
      function hide(){
        if(shown){
          wrap.classList.remove('sf-in');
          shown = false;
        }
      }

      // Near-footer hiding (optional)
      let nearEl = null;
      if(nearSel){
        try{ nearEl = document.querySelector(nearSel); }catch(e){}
      }

      function nearFooter(){
        if(!nearEl) return false;
        const rect = nearEl.getBoundingClientRect();
        const vh = window.innerHeight || document.documentElement.clientHeight;
        // If the top of the target is within nearMargin of viewport bottom, consider "near"
        return (rect.top - vh) < nearMargin;
      }

      // Core logic: reveal after threshold and optionally only when scrolling up
      function onScroll(){
        const y = window.scrollY || window.pageYOffset;

        // Hide if near footer to avoid overlap
        if(nearFooter()){ hide(); lastY = y; return; }

        const passed = y > after;
        const scrollingUp = y < lastY - 2; // small hysteresis
        const scrollingDown = y > lastY + 2;

        if(!upOnly){
          // Basic: show after threshold, hide if above
          if(passed) show(); else hide();
        } else {
          // Up-only: show when user scrolls up past threshold; hide on down
          if(passed && scrollingUp) show();
          if(scrollingDown) hide();
          if(!passed) hide();
        }

        lastY = y;
      }

      // Throttle (animation frame)
      let ticking = false;
      function rafScroll(){
        if(!ticking){
          window.requestAnimationFrame(() => {
            onScroll();
            ticking = false;
          });
          ticking = true;
        }
      }

      // First paint
      onScroll();

      // Listeners
      window.addEventListener('scroll', rafScroll, { passive: true });
      window.addEventListener('resize', rafScroll);

      // Reduced motion: still handles show/hide instantly
      if(prefersReduced){
        wrap.style.transitionDuration = '1ms';
      }
    })();
  </script>

</body>

How it works

  • A fixed .sf-wrap sits at the bottom, initially translated out of view.
  • The script listens for scroll and toggles .sf-in based on:data-sf-after (pixels scrolled before eligible),data-sf-up-only (only reveal on upward scroll), andproximity to a selector (e.g., footer) to avoid overlapping the real footer.
  • A dismiss button persists the choice via localStorage (or sessionStorage).

Quick customization

Per-bar (data attributes):

  • data-sf-after="280" — show after 280px scrolled.
  • data-sf-up-only="true" — only reveal when user scrolls up.
  • data-sf-hide-near="footer" — CSS selector to hide when near that element.
  • data-sf-near-margin="320" — how close (px) to the selector before hiding.
  • data-sf-storage="promo-q4" — persistence key for dismiss.
  • data-sf-session="true" — use sessionStorage instead of localStorage.

Content & actions:

  • Replace the lead copy, link the primary button to a collection or product.
  • Add any Shopify Liquid content inside .sf-bar (cart total, dynamic code, etc.).

Global feel (CSS tokens):

  • --sf-duration, --sf-ease, --sf-shadow, --sf-border, --sf-accent.

Performance & accessibility notes

  • Uses transform animations (GPU-friendly) and passive scroll listeners + rAF throttling.
  • Honors prefers-reduced-motion by effectively removing the slide animation.
  • aria-live="polite" announces copy changes gently if you swap content dynamically.
  • Avoid too-tall footers on mobile; keep actions concise and tap-targets ≥44px.
  • Compress images/icons if you add them; don’t overdo box-shadows on low-end devices.

Turning this into a Liquid section (optional)

Create /sections/sticky-footer.liquid, move the markup inside, and add a JSON schema to expose fields for copy, buttons, thresholds, up-only toggle, and storage key. The CSS/JS can remain co-located or be moved to theme assets; behavior is identical.