Developer Notes

Smooth Scroll Reveal in Astro 6

A technical note on implementing subtle, accessible scroll reveal animations that work seamlessly with Astro's ClientRouter.

Smooth scroll reveal animation preview in an Astro 6 project.

Motion in an interface can give a page a sense of weight and intention. When an element slides into view with a slight delay, it tells the visitor that the content is being “served” rather than just dumped onto the screen.

However, with Astro 6 and the <ClientRouter /> (formerly View Transitions), standard scroll animation libraries often struggle. They either trigger too early, fail to re-initialize after a page swap, or create a noticeable “glitch” where elements flash before they are hidden.

Implementing a focused system for this requires handling a few Astro-specific details to keep the experience smooth and accessible.

The Approach

The system relies on three parts: CSS for the state and accessibility, an IntersectionObserver to detect when elements enter the viewport, and Astro event listeners to keep everything in sync during navigation.

1. The Styles

The “hidden” state defines the starting point. A slight upward movement and a complete fade-out create a clean foundation. To ensure the site remains fully accessible, we also include a media query that completely disables animations for users who prefer reduced motion.

/* src/styles/reveal.css */
.reveal {
  opacity: 0;
  transform: translateY(1rem);
  transition: 
    opacity 0.6s cubic-bezier(0.2, 0.8, 0.2, 1),
    transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Cancel animations for users with prefers-reduced-motion active */
@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
  }
}

2. The Script

The JavaScript part uses a single IntersectionObserver. This is much more efficient than listening to the scroll event, as the browser handles the visibility detection natively.

The key for Astro is handling its lifecycle events. The astro:page-load event fires every time a new page is loaded, whether it’s the first visit or a transition through the ClientRouter.

// src/scripts/reveal.js
let revealObserver;

function initReveal() {
  // Disconnect any existing tracking before scanning the new DOM
  if (revealObserver) revealObserver.disconnect();

  const revealElements = document.querySelectorAll(".reveal");
  if (revealElements.length === 0) return;

  revealObserver = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add("is-visible");
        revealObserver.unobserve(entry.target);
      }
    });
  }, { threshold: 0.1, rootMargin: "0px 0px -20px 0px" });

  revealElements.forEach((el) => {
    revealObserver.observe(el);
  });
}

function cleanupReveal() {
  if (revealObserver) {
    revealObserver.disconnect();
    revealObserver = null;
  }
}

// Initialize on initial load and subsequent navigation
document.addEventListener("astro:page-load", initReveal);

// Clean up before swapping content to prevent memory leaks
document.addEventListener("astro:before-swap", cleanupReveal);

3. Handling Transitions

Using astro:before-swap ensures that the old observer is properly disconnected before Astro replaces the body element. This prevents detached DOM nodes from causing memory leaks behind the scenes when users navigate through multiple pages.

Another important detail: if you have elements that should not be affected by Astro’s built-in transition animations, adding transition:animate="none" to the element helps avoid conflicts with the manual reveal logic.

Simplicity and Performance

A personal site is a place for experimentation. You can add layers of motion, complex staggering, and heavy libraries. But every layer adds complexity to the performance profile and the maintenance of the site.

In many cases, the most resilient solution is the one that stays close to the browser’s primitives. And sometimes, the best choice is to strip away the motion entirely to keep the site feeling as fast and clean as possible. Whether you use these techniques or stick to a static layout, the goal remains the same: a site that stays out of the way of the content.

— Serhii Nadolskyi