Mobile nebula: keep left monitor + parallax, glide-and-fade with the hero

A phone has no left gutter like desktop, and the nebula was position:fixed, so
scrolling slid the text up into the pinned monitor (overlap + jumps). Keep the
monitor to the left of the title and keep the parallax, but on mobile the field
glides up and out with the hero (slower-than-scroll lag for depth) and fades as it
leaves, so it never crosses the content below. Only the monitor/streamer layer
fades; the base gradient stays. Extra top room (pt-44) keeps it clear of the header
at rest. Desktop unchanged.

Dials: yRangeMobile (parallax), heroOpacity range (fade), pt-44 (top room).
This commit is contained in:
jwinterm
2026-06-07 08:05:34 -04:00
parent dd6943c7e8
commit 673d07a00e
2 changed files with 25 additions and 13 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ export default function Home() {
<HomeShell> <HomeShell>
<div <div
id="main-content" id="main-content"
className="z-10 w-full max-w-7xl mx-auto flex flex-col items-center pt-32 pb-10 px-4 text-center scroll-mt-28" className="z-10 w-full max-w-7xl mx-auto flex flex-col items-center pt-44 sm:pt-32 pb-10 px-4 text-center scroll-mt-28"
> >
<h1 <h1
id="hero-title" id="hero-title"
+24 -12
View File
@@ -88,11 +88,20 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches, typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches,
); );
// Parallax Scroll hooks (called unconditionally) // Scroll-driven motion (hooks called unconditionally).
const { scrollY } = useScroll(); const { scrollY } = useScroll();
// Desktop: gentle downward parallax. The monitor lives in the empty left gutter, so
// drifting down stays clear of text.
const yRange = useTransform(scrollY, [0, 1000], [0, 250]); const yRange = useTransform(scrollY, [0, 1000], [0, 250]);
// Mobile: there is no gutter, so instead of pinning the field to the screen we let it
// glide up and out with the hero (slower than scroll, which gives the same depth) and
// fade, so it never crosses the content below. PARALLAX/FADE ranges are easy dials.
const yRangeMobile = useTransform(scrollY, [0, 1000], [0, -650]);
const parallaxY = isMobile ? yRangeMobile : yRange;
const heroOpacity = useTransform(scrollY, [0, 450], [1, 0]);
// Position calculations // Monitor position: just to the left of the hero title, on every screen. The extra
// top room the hero gets on mobile (see page.tsx) keeps this clear of the header.
const computerX = position?.x ?? 18; const computerX = position?.x ?? 18;
const computerY = position?.y ?? 18; const computerY = position?.y ?? 18;
// Aim the streamers at the monitor's screen. +2.5 hit low-and-right; -1/-1.5 hit // Aim the streamers at the monitor's screen. +2.5 hit low-and-right; -1/-1.5 hit
@@ -111,9 +120,8 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
return () => mediaQuery.removeEventListener('change', handler); return () => mediaQuery.removeEventListener('change', handler);
}, []); }, []);
// On small screens the monitor and streamers have nowhere to sit without crossing // Track the mobile breakpoint so the monitor can be re-anchored and the parallax
// the hero text, and the scroll parallax drags them over it. Use the static glow // swapped for a fade.
// there instead.
useEffect(() => { useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)'); const mq = window.matchMedia('(max-width: 767px)');
setIsMobile(mq.matches); setIsMobile(mq.matches);
@@ -176,9 +184,8 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
background: `radial-gradient(circle at center, ${glowColor} 0%, transparent 70%)`, background: `radial-gradient(circle at center, ${glowColor} 0%, transparent 70%)`,
} as const; } as const;
// Static background for reduced-motion users and small screens (still Cherenkov-tinted, // Return static background for reduced motion users (still tinted Cherenkov blue, no motion).
// with no monitor or streamers to overlap the hero text). if (prefersReducedMotion) {
if (prefersReducedMotion || isMobile) {
return ( return (
<div className={cn( <div className={cn(
"absolute inset-0 overflow-hidden pointer-events-none z-0", "absolute inset-0 overflow-hidden pointer-events-none z-0",
@@ -198,7 +205,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
return ( return (
<div className={cn("absolute inset-0 overflow-hidden pointer-events-none z-0", className)}> <div className={cn("absolute inset-0 overflow-hidden pointer-events-none z-0", className)}>
{/* Base Cherenkov gradient wash */} {/* Base Cherenkov gradient wash (always on; only the active layer below fades) */}
<div className={cn( <div className={cn(
"absolute inset-0", "absolute inset-0",
themeMode === 'light' themeMode === 'light'
@@ -206,10 +213,14 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
: "bg-gradient-to-br from-slate-950 via-slate-900/80 to-[#0a1622]" : "bg-gradient-to-br from-slate-950 via-slate-900/80 to-[#0a1622]"
)} /> )} />
{/* Monitor + streamers + glow. On mobile this layer glides up and fades as the
hero scrolls away, so it never crosses the content below. */}
<motion.div className="absolute inset-0" style={{ opacity: isMobile ? heroOpacity : undefined }}>
{/* Slowly-pulsing Cherenkov "reactor pool" bloom */} {/* Slowly-pulsing Cherenkov "reactor pool" bloom */}
<motion.div <motion.div
className="absolute h-[70vmax] w-[70vmax] -translate-x-1/2 -translate-y-1/2" className="absolute h-[70vmax] w-[70vmax] -translate-x-1/2 -translate-y-1/2"
style={{ ...glowStyle, left: `${computerX}%`, top: `${computerY}%`, y: yRange }} style={{ ...glowStyle, left: `${computerX}%`, top: `${computerY}%`, y: parallaxY }}
initial={{ opacity: 0, scale: 0.85 }} initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: [0.55, 0.9, 0.55], scale: [0.95, 1.05, 0.95] }} animate={{ opacity: [0.55, 0.9, 0.55], scale: [0.95, 1.05, 0.95] }}
transition={{ duration: 11, ease: "easeInOut", repeat: Infinity }} transition={{ duration: 11, ease: "easeInOut", repeat: Infinity }}
@@ -226,7 +237,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
style={{ style={{
left: `${computerX}%`, left: `${computerX}%`,
top: `${computerY}%`, top: `${computerY}%`,
y: yRange y: parallaxY
}} }}
> >
<motion.div animate={monitorControls}> <motion.div animate={monitorControls}>
@@ -244,7 +255,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
viewBox="0 0 100 100" viewBox="0 0 100 100"
preserveAspectRatio="none" preserveAspectRatio="none"
className="w-full h-full opacity-50 dark:opacity-70 absolute inset-0" className="w-full h-full opacity-50 dark:opacity-70 absolute inset-0"
style={{ y: yRange }} style={{ y: parallaxY }}
> >
{paths.map((d: string, i: number) => ( {paths.map((d: string, i: number) => (
<StreamLine <StreamLine
@@ -257,6 +268,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
/> />
))} ))}
</motion.svg> </motion.svg>
</motion.div>
</div> </div>
); );
}; };