mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
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:
@@ -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" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user