Scroll animations are everywhere, and most of them are done badly. A heavy library loads on every page, elements animate every single time they scroll past, the layout jumps while things settle, and nobody thought about the visitor who gets motion sick. The effect is nice. The execution quietly costs you performance and accessibility. This is how to add scroll animations to HubSpot CMS the right way: no library, no layout shift, and reduced motion respected, in about thirty lines of code.
Prefer not to hand-code it? This guide is about reveal-on-scroll. For animated backgrounds — mesh gradients, aurora, liquid, dot and grid patterns and more — we built a free Background Generator. Pick an effect, tweak the colours, and copy clean, dependency-free code that respects reduced motion. It sits with our other free HubSpot tools.
Before the fix, it helps to name the common mistakes, because you have almost certainly seen all four on the same site.
The right way needs two things the browser already gives you for free. IntersectionObserver tells you when an element enters the viewport without you listening to the scroll event on every pixel. CSS transitions do the actual animating on the GPU. The JavaScript never animates anything. It only adds a class. That single decision is what keeps this cheap, because the main thread stays free and only opacity and transform change, neither of which causes layout shift.
Elements start invisible and nudged down. When they get the is-visible class, they settle into place. Add this to your theme stylesheet or a module's CSS:
/* start hidden and slightly lower */
[data-reveal]{
opacity:0;
transform:translateY(18px);
transition:opacity .6s cubic-bezier(.22,.61,.36,1), transform .6s cubic-bezier(.22,.61,.36,1);
will-change:opacity, transform;
}
[data-reveal].is-visible{ opacity:1; transform:none; }
/* stagger the children of a group */
[data-stagger] > *{ transition-delay:calc(var(--i,0) * 90ms); }
/* motion-sensitive visitors: no movement, no delay, just show */
@media (prefers-reduced-motion: reduce){
[data-reveal]{ opacity:1; transform:none; transition:none; }
[data-stagger] > *{ transition-delay:0ms; }
}
Two things here matter more than they look. Animating only opacity and transform means the browser can composite the animation on the GPU, so the rest of your page never moves. And the reduced-motion block is not an afterthought, it is the first thing that makes this responsible: those visitors see the content immediately, with no movement at all.
This finds every element you tagged, watches it, and reveals it once. Roughly twenty lines, no dependencies:
(function () {
var els = document.querySelectorAll('[data-reveal]');
if (!els.length) return;
// reduced motion, or no support: show everything now, animate nothing
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !('IntersectionObserver' in window)) {
els.forEach(function (el) { el.classList.add('is-visible'); });
return;
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
io.unobserve(entry.target); // one-shot: reveal once, then stop watching
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -10% 0px' });
els.forEach(function (el) { io.observe(el); });
})();
The two lines that separate this from the copy-paste snippets online: io.unobserve(entry.target) makes each reveal fire exactly once and then stops watching, so scrolling back up does not replay it and the observer stops doing work. And the early return handles both reduced motion and any browser without support, by simply showing everything. There is no state where a visitor stares at an invisible page.
Add the CSS and JS once, in your theme (or a module that you drop on the page). After that, animating anything is a matter of adding one attribute. No per-element configuration, no module settings to wire up:
<h2 data-reveal>This heading fades up when it enters the viewport</h2>
<p data-reveal>So does this paragraph, a moment later.</p>
<div class="cards" data-stagger>
<div class="card" data-reveal style="--i:0">One</div>
<div class="card" data-reveal style="--i:1">Two</div>
<div class="card" data-reveal style="--i:2">Three</div>
</div>
Any element with data-reveal fades up when it scrolls in. Because the script keys off an attribute and not an ID, it is safe to reuse across every template and module without collisions.
Reveals feel best when a row of cards enters one after another rather than all at once. That is the data-stagger parent and the --i index in the markup above. Each child waits ninety milliseconds longer than the last, so a grid cascades in. It is pure CSS, so it costs nothing extra and it disappears automatically under reduced motion.
Before you ship it, the five things that keep this on the right side of the line:
You now have a reveal system that works across your whole HubSpot theme from a single attribute, weighs almost nothing, and treats performance and accessibility as part of the effect rather than an afterthought. That is the difference between animation that makes a site feel considered and animation that just makes it feel busy.
If you want motion built into your theme properly, tuned and tested across devices, that is the kind of thing we do. See how we work at Studio Nope, or grab one of our free HubSpot tools.