Appear Animations

Subtle scroll-based animations can make a page feel alive without pulling in a large library like GSAP. We can build a lightweight “appear” helper by combining:

  • IntersectionObserver → detect when elements enter the viewport.
  • CSS transitions → handle the actual fade/slide/scale animations.
  • A simple .is-visible toggle → applied once, no reflows.

This balances performance and user experience while respecting prefers-reduced-motion.


CSS

Instead of hardcoding hidden/visible styles globally, we can keep things safe: if the JavaScript never loads, elements remain visible. The script injects the CSS rules dynamically.

.appear {
  --appear-translate: 128px;

  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.5s ease, transform 0.5s ease;
  will-change: opacity, transform;

  /* appeared */
  &.is-visible {
    opacity: 1;
    transform: none;
  }
}

/* Variants override the baseline transform */
.appear-up { 
  transform: translateY(var(--appear-translate)); 
}
.appear-down { 
  transform: translateY(calc(var(--appear-translate) * -1));
}
.appear-left { 
  transform: translateX(var(--appear-translate)); 
}
.appear-right { 
  transform: translateX(calc(var(--appear-translate) * -1)); 
}
.appear-scale { 
  transform: scale(0.95); 
}

JavaScript

The script observes .appear elements and applies .is-visible when they enter the viewport:

    // No IO support → reveal immediately (again: no hiding CSS injected).
const revealAllNow = () => {
    const show = () => {
        document
            .querySelectorAll('.appear')
            .forEach(el => el.classList.add('is-visible'))
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', show, { once: true })
    } else {
        show()
    }
}

// Respect reduced motion → don't inject the hiding CSS; just reveal.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    revealAllNow()
} else if (!('IntersectionObserver' in window)) {
    // No IO support → reveal immediately (again: no hiding CSS injected).
    revealAllNow()
} else {
    // JS present → inject CSS (so elements can start hidden) and observe
    injectCSS()

    const onIntersect = entries => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                entry.target.classList.add('is-visible')
                io.unobserve(entry.target)
            }
        })
    }

    const io = new IntersectionObserver(onIntersect, { threshold: 0.2 })

    const start = () => {
        const targets = document.querySelectorAll('.appear')
        if (!targets.length) return
        targets.forEach(el => io.observe(el))
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', start, { once: true })
    } else {
        start()
    }
}

Usage

<!-- Inlcude in <head> or template, etc. -->
<script defer src="/static/js/apps/appear.js"></script>

<h2 class="appear appear-up">Fade In Heading</h2>

<p class="appear appear-scale">This paragraph scales up slightly as it fades in.</p>

<div class="appear appear-left">Slide from left</div>
<div class="appear appear-right">Slide from right</div>

Progressive Enhancement

⚠️ JavaScript is required for this effect.
If the script doesn’t load, .appear elements will remain visible by default. The animations are a progressive enhancement — the page works fine without them.


✨ That’s all it takes: a sprinkle of CSS + IntersectionObserver for lightweight appear animations.