Files
such.software/apps/web/src/components/scroll-reveal.tsx
T
jwinterm 128d154b0e Trim homepage JS: drop framer-motion from the first load; cursor lead-in blinks
- Remove framer-motion from the two components that pulled it into the homepage's
  initial bundle: HeroSplash (click-to-enter wash -> CSS keyframe; reduced-motion
  check moved into the handler) and ScrollReveal (whileInView -> IntersectionObserver
  + CSS transition). framer now only lives in the lazy nebula chunk, which never loads
  during the splash. First Load JS on / : 174 kB -> 138 kB.
- Monitor terminal: blink ~7 times before typing 'such' (so it's catchable even when
  the splash is skipped on refresh), then type/hold/backspace/blink/fade as before.

Desktop behavior unchanged; both converted components still respect reduced-motion.
2026-06-07 16:12:17 -04:00

79 lines
2.2 KiB
TypeScript

'use client';
import { createElement, useEffect, useRef, useState, type ReactNode } from 'react';
type Direction = 'up' | 'down' | 'left' | 'right' | 'none';
type Tag = 'div' | 'section' | 'article' | 'li';
interface ScrollRevealProps {
children: ReactNode;
direction?: Direction;
delay?: number;
duration?: number;
distance?: number;
amount?: number;
className?: string;
as?: Tag;
}
const hiddenTransform = (direction: Direction, distance: number) => {
switch (direction) {
case 'up': return `translateY(${distance}px)`;
case 'down': return `translateY(${-distance}px)`;
case 'left': return `translateX(${distance}px)`;
case 'right': return `translateX(${-distance}px)`;
case 'none': return 'translate(0, 0)';
}
};
/**
* Reveal-on-scroll wrapper. Uses an IntersectionObserver + a CSS transition instead of
* framer-motion, so it adds no JS animation library to the bundle (transform/opacity
* run on the compositor). Respects prefers-reduced-motion.
*/
export function ScrollReveal({
children,
direction = 'up',
delay = 0,
duration = 0.6,
distance = 24,
amount = 0.2,
className,
as = 'div',
}: ScrollRevealProps) {
const ref = useRef<HTMLElement | null>(null);
const [shown, setShown] = useState(false);
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setReduced(true);
return;
}
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShown(true);
io.disconnect();
}
},
{ threshold: amount },
);
io.observe(el);
return () => io.disconnect();
}, [amount]);
const style = reduced
? undefined
: {
opacity: shown ? 1 : 0,
transform: shown ? 'translate(0, 0)' : hiddenTransform(direction, distance),
transition: `opacity ${duration}s cubic-bezier(0.16, 1, 0.3, 1) ${delay}s, transform ${duration}s cubic-bezier(0.16, 1, 0.3, 1) ${delay}s`,
willChange: 'opacity, transform',
};
return createElement(as, { ref, className, style }, children);
}