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
- Online Store → Themes → Customize
- Open the page/template you want (Home, Collection, Blog, etc.)
- Add section → Custom HTML (or Custom Liquid)
- Paste the entire snippet below
- Save & preview on desktop and mobile
Copy-Paste Example — Custom Scroll-Trigger System
Paste everything from <!DOCTYPE html> to </html>.
<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.