Skip to main content

Overview

Jowy Portfolio features sophisticated animation systems including scroll-triggered reveal animations, a neon intro sequence, and smooth transitions. All animations are performance-optimized using the IntersectionObserver API and respect user preferences for reduced motion.

Scroll Reveal Animations

The core animation system uses the animate-on-scroll class to reveal elements as they enter the viewport.

CSS Implementation

/* Initial state: hidden and translated down */
.animate-on-scroll {
  opacity: 0;
  transform: translateY(30px);
  transition:
    opacity 0.8s ease-out,
    transform 0.8s ease-out;
  will-change: opacity, transform;
}

/* Visible state: fully opaque at original position */
.animate-on-scroll.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Respect user preferences */
@media (prefers-reduced-motion: reduce) {
  .animate-on-scroll {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
The will-change: opacity, transform; property hints to the browser that these properties will animate, allowing it to optimize rendering. Use sparingly as it consumes memory.

JavaScript Controller

The src/utils/animations.ts file initializes the scroll animation system:
/**
 * Initializes scroll-triggered reveal animations for elements
 * with the 'animate-on-scroll' class.
 * Uses IntersectionObserver for optimal performance.
 */
export const initScrollAnimations = () => {
  // Select elements on current page only
  const elements = document.querySelectorAll(".animate-on-scroll");

  // Exit early if no animated elements exist
  if (elements.length === 0) return;

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add("is-visible");
          // Stop observing once animated (performance)
          observer.unobserve(entry.target);
        }
      });
    },
    {
      threshold: 0.15, // Trigger when 15% of element is visible
    }
  );

  elements.forEach((el) => observer.observe(el));
};
The observer unobserves each element after animation to improve performance. Once revealed, elements don’t need continuous observation.

Usage in Components

Simply add the class to any element:
<section class="animate-on-scroll w-full">
  <h2>This section fades in on scroll</h2>
  <p>Content appears smoothly...</p>
</section>
<div class="animate-on-scroll">
  <!-- Animates when visible -->
</div>

Neon Intro Sequence

The landing page features an animated “JOWY” text intro with neon glow effects.

HTML Structure

{
  isLanding && (
    <div
      id="intro-overlay"
      class="fixed inset-0 z-99999 flex h-full w-full items-center 
             justify-center bg-black transition-opacity duration-1000 
             ease-in-out"
    >
      <div class="flex h-full w-full items-center justify-center 
                  overflow-hidden text-6xl font-bold text-white md:text-9xl">
        <span class="neon-style inline-block translate-y-20 font-extrabold 
                     opacity-0 transition-all duration-700 ease-out">
          J
        </span>
        <span class="neon-style inline-block translate-y-20 font-extrabold 
                     opacity-0 transition-all delay-100 duration-700 ease-out">
          O
        </span>
        <span class="neon-style inline-block translate-y-20 font-extrabold 
                     opacity-0 transition-all delay-200 duration-700 ease-out">
          W
        </span>
        <span class="neon-style inline-block translate-y-20 font-extrabold 
                     opacity-0 transition-all delay-300 duration-700 ease-out">
          Y
        </span>
      </div>
    </div>
  )
}

J

0ms delay

O

100ms delay

W

200ms delay

Y

300ms delay

Animation Controller

The intro sequence is orchestrated in BaseLayout.astro:
document.addEventListener("astro:page-load", () => {
  const main = document.getElementById("main-wrapper");
  const navIcons = document.getElementById("nav-icons");
  const overlay = document.getElementById("intro-overlay");
  const isLanding = main?.dataset.isLanding === "true";

  if (!isLanding) {
    (window as any).hasPlayedIntro = false;
  } else {
    const chars = document.querySelectorAll("#intro-overlay .neon-style");

    if (!(window as any).hasPlayedIntro) {
      // 1. Staggered entrance animation
      setTimeout(() => {
        chars.forEach((char) => {
          char.classList.remove("opacity-0", "translate-y-20");
        });
      }, 100);

      // 2. Staggered exit animation
      setTimeout(() => {
        chars.forEach((char) => {
          char.classList.add("opacity-0", "translate-y-20");
        });
      }, 1300);

      // 3. Fade out overlay, fade in main content
      setTimeout(() => {
        if (overlay) overlay.classList.add("opacity-0");
        if (main) main.classList.remove("opacity-0");
        if (navIcons) navIcons.classList.remove("opacity-0");
        window.dispatchEvent(new CustomEvent("intro-reveal"));

        // Remove overlay from DOM after transition
        setTimeout(() => {
          if (overlay) overlay.style.display = "none";
        }, 700);
      }, 2300); // Total intro duration

      (window as any).hasPlayedIntro = true;
    } else {
      // Skip intro if already seen
      if (overlay) overlay.style.display = "none";
      if (main) main.classList.remove("opacity-0");
      if (navIcons) navIcons.classList.remove("opacity-0");
      window.dispatchEvent(new CustomEvent("intro-reveal"));
    }
  }
});
1

Check play state

Use window.hasPlayedIntro to ensure intro plays only once per session
2

Entrance (100ms)

Letters slide up and fade in with staggered delays
3

Hold (1200ms)

Full “JOWY” text visible at peak glow
4

Exit (1000ms)

Letters fade out and slide up
5

Reveal content (700ms)

Main page content fades in as overlay fades out

Theme Toggle Animation

The theme button includes a smooth icon crossfade:
<button
  id="theme-toggle"
  class="transition duration-300 ease-in-out hover:scale-120"
>
  <Image
    id="theme-toggle-icon"
    class="transition-opacity duration-500 ease-in-out"
  />
</button>

<script>
  toggleButton.addEventListener("click", () => {
    icon.style.opacity = "0";  // Fade out

    const currentTheme = getCurrentTheme() === "dark" ? "light" : "dark";
    updateTheme(currentTheme);

    setTimeout(() => {
      icon.src = THEME_ICONS[currentTheme].src;  // Swap icon
      icon.style.opacity = "1";  // Fade in
    }, 200);
  });
</script>
The 200ms delay allows the fade-out animation to complete before swapping the icon source, creating a seamless crossfade effect.

Transition Classes

Common Tailwind transition patterns used throughout:

Hover Scale

<div class="transition duration-300 ease-in-out hover:scale-110">
  Grows 10% on hover
</div>

Color Transitions

<body class="transition-colors duration-500 ease-in-out">
  Smooth theme color changes
</body>

Opacity Fades

<div class="transition-opacity duration-1000 ease-in-out opacity-0">
  Fade effect
</div>
Quick interactions like button hovers
duration-300

Astro View Transitions

Page navigation uses Astro’s built-in transitions:
<html transition:animate="none">
  <!-- Disable default animation -->
</html>

<body transition:animate="fade">
  <!-- Custom fade transition -->
</body>
The ClientRouter component enables SPA-like navigation:
---
import { ClientRouter } from "astro:transitions";
---
<ClientRouter />

Accessibility Considerations

Reduced Motion Support

@media (prefers-reduced-motion: reduce) {
  .animate-on-scroll {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
Always respect prefers-reduced-motion. Users with vestibular disorders or motion sensitivity can experience discomfort from animations.

Focus Indicators

Ensure interactive animated elements maintain visible focus states:
<button class="transition hover:scale-110 focus:ring-2 focus:ring-primary">
  Accessible animated button
</button>

Performance Best Practices

Use IntersectionObserver

More performant than scroll event listeners. Automatically throttled by browser.

Unobserve After Animation

Stop observing elements once animated to free up resources

GPU-Accelerated Properties

Animate transform and opacity for best performance (GPU-accelerated)

will-change Sparingly

Only use on actively animating elements. Remove after animation completes.

Animation Library Integration

The project includes animation libraries for advanced effects:
{
  "dependencies": {
    "framer-motion": "^12.23.12",
    "gsap": "^3.13.0"
  }
}
React-style declarative animations (if using React components)

Debugging Animations

Check Observer Status

console.log('Animated elements:', document.querySelectorAll('.animate-on-scroll').length);

Test Threshold

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      console.log('Intersection ratio:', entry.intersectionRatio);
    });
  },
  { threshold: 0.15 }
);

Verify Reduced Motion

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
console.log('Reduced motion:', prefersReducedMotion);

Build docs developers (and LLMs) love