Files
portfolio/app/components/ScrollFadeIn.tsx
T
denshooter c847a456a3 feat: polish motion with stronger easing, press feedback and reduced motion
- Override Tailwind ease-out/ease-in-out with stronger curves, add ease-drawer
- hoverOnlyWhenSupported so hover styles never stick on touch devices
- Replace transition-all with explicit properties across the public UI
- Press feedback via active:scale-[0.97] instead of hover:scale-105
- Global prefers-reduced-motion handling (CSS + MotionConfig reducedMotion="user")
- Mobile menu is a real drawer: stays mounted, inert when closed, Escape closes
- Progress and music bars animate scaleX/scaleY instead of width/height
- Fix undefined animate-spin-slow, contain hero CTA shine with overflow-hidden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:31:58 +02:00

64 lines
1.8 KiB
TypeScript

"use client";
import { useRef, useEffect, useState, type ReactNode } from "react";
interface ScrollFadeInProps {
children: ReactNode;
className?: string;
delay?: number;
}
/**
* Wraps children in a fade-in-up animation triggered by scroll.
* Unlike Framer Motion's initial={{ opacity: 0 }}, this does NOT
* render opacity:0 in SSR HTML — content is visible by default
* and only hidden after JS hydration for the animation effect.
*/
export default function ScrollFadeIn({ children, className = "", delay = 0 }: ScrollFadeInProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
const [reduceMotion, setReduceMotion] = useState(false);
useEffect(() => {
setHasMounted(true);
setReduceMotion(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const el = ref.current;
if (!el) return;
// Fallback for browsers without IntersectionObserver
if (typeof IntersectionObserver === "undefined") {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(el);
}
},
// Start a little before the section is fully in view so it never feels late
{ threshold: 0.1, rootMargin: "0px 0px -60px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={hasMounted ? {
opacity: isVisible ? 1 : 0,
transform: isVisible || reduceMotion ? "translateY(0)" : "translateY(16px)",
transition: `opacity 0.6s var(--ease-out) ${delay}s, transform 0.6s var(--ease-out) ${delay}s`,
} : undefined}
>
{children}
</div>
);
}