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.
This commit is contained in:
jwinterm
2026-06-07 16:12:17 -04:00
parent a2955b4102
commit 128d154b0e
3 changed files with 55 additions and 52 deletions
+10 -3
View File
@@ -234,6 +234,11 @@
@keyframes splash-zoom { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1.14); } }
.splash-zoom { animation: splash-zoom 0.8s ease-in-out forwards; }
/* Click-to-enter Cherenkov wash (was framer-motion; now CSS so the splash ships no
JS animation library). */
@keyframes splash-wash { 0% { opacity: 0; } 50% { opacity: 0.9; } 100% { opacity: 0; } }
.splash-wash { animation: splash-wash 0.8s ease-in-out forwards; }
/* Tiny terminal inside the nebula monitor: type "such" once, backspace, then a blinking
caret on the left. width via steps() is the typewriter; border-right is the caret. */
.nebula-term {
@@ -247,10 +252,12 @@
border-right: 1.5px solid currentColor;
/* type "such", hold, backspace (nebula-type, once); blink a finite number of times
(nebula-caret); then fade the whole thing out so it stops being distracting. */
/* blink ~7 times first (so you catch it even when the splash is skipped on refresh),
then type "such", hold, backspace, blink a few more, and fade away. */
animation:
nebula-type 3s steps(4, end) forwards,
nebula-caret 0.8s steps(1, end) 10,
nebula-term-out 0.6s ease-in 7.4s forwards;
nebula-type 3s steps(4, end) 5.6s forwards,
nebula-caret 0.8s steps(1, end) 17,
nebula-term-out 0.6s ease-in 13s forwards;
}
@keyframes nebula-type {
0% { width: 0; }
@@ -1,7 +1,6 @@
"use client";
import Link from "next/link";
import { motion, useReducedMotion } from "framer-motion";
import { ChevronDown, LayoutGrid, Smartphone, Wrench, Info } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SPLASH_LOGO_SRC } from "./splash-logo-data";
@@ -19,7 +18,6 @@ const navButtons = [
// tagline text is hidden, leaving the logo as the only large element).
export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => void; sectionRef?: any; leaving?: boolean }) {
const prefersReduced = useReducedMotion();
const [entering, setEntering] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
const dispRef = useRef<SVGFEDisplacementMapElement>(null);
@@ -35,7 +33,7 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
const handleEnter = () => {
if (entering) return;
if (prefersReduced) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
onEnter();
return;
}
@@ -75,13 +73,10 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
{/* Click-to-enter: soft Cherenkov wash. The ripple itself is the SVG water
filter refracting the logo (driven by handleEnter's rAF loop). */}
{entering && (
<motion.div
<div
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-20 backdrop-blur-sm"
className="splash-wash pointer-events-none fixed inset-0 z-20 backdrop-blur-sm"
style={{ background: "radial-gradient(circle at center, rgba(34,211,238,0.28), transparent 65%)" }}
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.9, 0] }}
transition={{ duration: 0.8, ease: "easeInOut" }}
/>
)}
+42 -41
View File
@@ -1,7 +1,6 @@
'use client';
import { motion, useReducedMotion, type Variants } from 'framer-motion';
import { createElement, type ReactNode } from 'react';
import { createElement, useEffect, useRef, useState, type ReactNode } from 'react';
type Direction = 'up' | 'down' | 'left' | 'right' | 'none';
type Tag = 'div' | 'section' | 'article' | 'li';
@@ -17,16 +16,21 @@ interface ScrollRevealProps {
as?: Tag;
}
const axisFor = (direction: Direction, distance: number) => {
const hiddenTransform = (direction: Direction, distance: number) => {
switch (direction) {
case 'up': return { x: 0, y: distance };
case 'down': return { x: 0, y: -distance };
case 'left': return { x: distance, y: 0 };
case 'right': return { x: -distance, y: 0 };
case 'none': return { x: 0, y: 0 };
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',
@@ -37,41 +41,38 @@ export function ScrollReveal({
className,
as = 'div',
}: ScrollRevealProps) {
const prefersReducedMotion = useReducedMotion();
const ref = useRef<HTMLElement | null>(null);
const [shown, setShown] = useState(false);
const [reduced, setReduced] = useState(false);
if (prefersReducedMotion) {
return createElement(as, { className }, children);
}
const offset = axisFor(direction, distance);
const variants: Variants = {
hidden: { opacity: 0, ...offset },
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration,
delay,
ease: [0.16, 1, 0.3, 1],
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 common = {
className,
initial: 'hidden',
whileInView: 'visible',
viewport: { once: true, amount },
variants,
children,
};
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',
};
const Component =
as === 'section' ? motion.section :
as === 'article' ? motion.article :
as === 'li' ? motion.li :
motion.div;
return createElement(Component as never, common);
return createElement(as, { ref, className, style }, children);
}