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.
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 ( 30 px );
transition :
opacity 0.8 s ease-out ,
transform 0.8 s 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 >
Single Element
Multiple Sections
< div class = "animate-on-scroll" >
<!-- Animates when visible -->
</ div >
< section class = "animate-on-scroll" > Section 1 </ section >
< section class = "animate-on-scroll" > Section 2 </ section >
< section class = "animate-on-scroll" > Section 3 </ section >
Each section animates independently
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 >
)
}
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" ));
}
}
});
Check play state
Use window.hasPlayedIntro to ensure intro plays only once per session
Entrance (100ms)
Letters slide up and fade in with staggered delays
Hold (1200ms)
Full “JOWY” text visible at peak glow
Exit (1000ms)
Letters fade out and slide up
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 >
Fast (300ms)
Medium (500ms)
Slow (700-1000ms)
Quick interactions like button hovers Theme changes and page transitions Dramatic reveals and overlays duration-700
duration-1000
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 >
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)
Professional-grade timeline animations and complex sequences
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 );