Add scroll progress indicator
This commit is contained in:
+145
-44
@@ -1,58 +1,159 @@
|
||||
import { MagneticHoverShowcase } from "@/components/magnetic-hover-showcase";
|
||||
import { ScrollProgressIndicator } from "@/components/scroll-progress-indicator";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen px-8 py-16 md:px-16 lg:px-24">
|
||||
<section className="mx-auto max-w-4xl space-y-16">
|
||||
<div className="space-y-8">
|
||||
<h1 className="font-bold font-heading text-5xl md:text-7xl">Space Grotesk</h1>
|
||||
<p className="font-body text-xl leading-relaxed md:text-2xl">
|
||||
This is Outfit, the body font. It's clean, modern, and highly readable across all
|
||||
sizes. The neo-noir palette creates a cinematic feel with warm off-white text on deep
|
||||
dark blue-grey.
|
||||
</p>
|
||||
<p className="font-cursive text-3xl md:text-4xl">
|
||||
And this is Caveat, for handwritten notes and journal entries...
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<ScrollProgressIndicator />
|
||||
|
||||
<div className="space-y-6">
|
||||
<h2 className="font-heading font-semibold text-3xl">Color Palette</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg border border-foreground/20 bg-background" />
|
||||
<p className="font-body text-sm">Background</p>
|
||||
<code className="text-xs opacity-60">oklch(0.16 0.02 255)</code>
|
||||
<main>
|
||||
<section
|
||||
id="landing"
|
||||
className="flex min-h-screen items-center justify-center px-8 py-16 md:px-16 lg:px-24"
|
||||
>
|
||||
<div className="mx-auto max-w-4xl space-y-8 text-center">
|
||||
<h1 className="font-bold font-heading text-6xl md:text-8xl">Welcome</h1>
|
||||
<p className="font-body text-xl leading-relaxed md:text-2xl">
|
||||
Scroll down to explore the sections and test the progress indicator.
|
||||
</p>
|
||||
<p className="font-cursive text-3xl text-accent-cyan md:text-4xl">
|
||||
Watch the dots on the right...
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" className="min-h-[150vh] px-8 py-16 md:px-16 lg:px-24">
|
||||
<div className="mx-auto max-w-4xl space-y-16">
|
||||
<div className="space-y-8">
|
||||
<h2 className="font-bold font-heading text-5xl md:text-7xl">About</h2>
|
||||
<p className="font-body text-xl leading-relaxed md:text-2xl">
|
||||
This is Outfit, the body font. It's clean, modern, and highly readable across
|
||||
all sizes. The neo-noir palette creates a cinematic feel with warm off-white text on
|
||||
deep dark blue-grey.
|
||||
</p>
|
||||
<p className="font-cursive text-3xl md:text-4xl">
|
||||
And this is Caveat, for handwritten notes and journal entries...
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg bg-foreground" />
|
||||
<p className="font-body text-sm">Foreground</p>
|
||||
<code className="text-xs opacity-60">oklch(0.94 0.02 90)</code>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-heading font-semibold text-3xl">Color Palette</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg border border-foreground/20 bg-background" />
|
||||
<p className="font-body text-sm">Background</p>
|
||||
<code className="text-xs opacity-60">oklch(0.16 0.02 255)</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg bg-foreground" />
|
||||
<p className="font-body text-sm">Foreground</p>
|
||||
<code className="text-xs opacity-60">oklch(0.94 0.02 90)</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg bg-accent-cyan" />
|
||||
<p className="font-body text-sm">Accent Cyan</p>
|
||||
<code className="text-xs opacity-60">oklch(0.75 0.14 195)</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg bg-accent-violet" />
|
||||
<p className="font-body text-sm">Accent Violet</p>
|
||||
<code className="text-xs opacity-60">oklch(0.58 0.19 300)</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg bg-accent-cyan" />
|
||||
<p className="font-body text-sm">Accent Cyan</p>
|
||||
<code className="text-xs opacity-60">oklch(0.75 0.14 195)</code>
|
||||
|
||||
<blockquote className="border-accent-cyan border-l-4 pl-6 italic">
|
||||
<p className="font-body text-xl">
|
||||
"Motion is the medium. Animation is not decoration, it's the point."
|
||||
</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="projects" className="min-h-screen px-8 py-16 md:px-16 lg:px-24">
|
||||
<div className="mx-auto max-w-4xl space-y-16">
|
||||
<div className="space-y-8">
|
||||
<h2 className="font-bold font-heading text-5xl md:text-7xl">Projects</h2>
|
||||
<p className="font-body text-xl leading-relaxed md:text-2xl">
|
||||
Interactive showcase of the magnetic spring hover effect from Story 1.5.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-24 rounded-lg bg-accent-violet" />
|
||||
<p className="font-body text-sm">Accent Violet</p>
|
||||
<code className="text-xs opacity-60">oklch(0.58 0.19 300)</code>
|
||||
|
||||
<MagneticHoverShowcase />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="travel" className="min-h-[200vh] px-8 py-16 md:px-16 lg:px-24">
|
||||
<div className="mx-auto max-w-4xl space-y-16">
|
||||
<div className="space-y-8">
|
||||
<h2 className="font-bold font-heading text-5xl md:text-7xl">Travel</h2>
|
||||
<p className="font-body text-xl leading-relaxed md:text-2xl">
|
||||
This section is intentionally very tall to simulate the 3D passport experience that
|
||||
will be built in Epic 5. The scroll indicator correctly tracks position regardless
|
||||
of section height.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div
|
||||
key={`travel-card-${i + 1}`}
|
||||
className="flex aspect-video items-center justify-center rounded-lg border border-foreground/10 bg-foreground/5"
|
||||
>
|
||||
<span className="font-heading text-2xl opacity-40">Travel Card {i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="py-32 text-center">
|
||||
<p className="font-cursive text-4xl text-accent-violet">The journey continues...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-heading font-semibold text-3xl">Accent Colors in Use</h2>
|
||||
<p className="text-xl">
|
||||
Links and highlights use{" "}
|
||||
<span className="text-accent-cyan">cyan for primary actions</span> and{" "}
|
||||
<span className="text-accent-violet">violet for secondary emphasis</span>.
|
||||
</p>
|
||||
</div>
|
||||
<section id="photography" className="min-h-[120vh] px-8 py-16 md:px-16 lg:px-24">
|
||||
<div className="mx-auto max-w-4xl space-y-16">
|
||||
<div className="space-y-8">
|
||||
<h2 className="font-bold font-heading text-5xl md:text-7xl">Photography</h2>
|
||||
<p className="font-body text-xl leading-relaxed md:text-2xl">
|
||||
A masonry gallery will be implemented here in Epic 6, featuring integration with
|
||||
Immich for photo management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MagneticHoverShowcase />
|
||||
</section>
|
||||
</main>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<div
|
||||
key={`photo-placeholder-${i + 1}`}
|
||||
className={`flex items-center justify-center rounded-lg border border-foreground/10 bg-foreground/5 ${
|
||||
i % 3 === 0 ? "aspect-3/4" : i % 3 === 1 ? "aspect-square" : "aspect-4/3"
|
||||
}`}
|
||||
>
|
||||
<span className="font-body text-sm opacity-40">Photo {i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="footer"
|
||||
className="flex min-h-[50vh] items-center px-8 py-16 md:px-16 lg:px-24"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl space-y-8 text-center">
|
||||
<h2 className="font-bold font-heading text-4xl md:text-6xl">End of Journey</h2>
|
||||
<p className="font-body text-lg opacity-80 md:text-xl">
|
||||
The footer section is intentionally short to demonstrate that the scroll indicator
|
||||
handles varying section heights correctly.
|
||||
</p>
|
||||
<div className="flex justify-center gap-6">
|
||||
<span className="text-accent-cyan">GitHub</span>
|
||||
<span className="text-accent-violet">LinkedIn</span>
|
||||
<span className="text-foreground/60">Email</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { m, useReducedMotion } from "motion/react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SECTION_LABELS, type SectionId } from "@/hooks/use-active-section";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
import { springSnappy } from "@/lib/motion";
|
||||
|
||||
export const PROXIMITY_THRESHOLD = 60;
|
||||
|
||||
type Props = {
|
||||
sectionId: SectionId;
|
||||
isActive: boolean;
|
||||
cursorPosition: { x: number; y: number } | null;
|
||||
onDotClick: (sectionId: SectionId, event: ReactMouseEvent) => void;
|
||||
};
|
||||
|
||||
export const Dot = ({ sectionId, isActive, cursorPosition, onDotClick }: Props) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const labelRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const {
|
||||
ref: magneticRef,
|
||||
style: magneticStyle,
|
||||
handlers,
|
||||
} = useMagneticSpringHover<HTMLButtonElement>({
|
||||
magnetStrength: 0.3,
|
||||
scaleAmount: 1.3,
|
||||
shadowElevation: 6,
|
||||
});
|
||||
|
||||
const [proximity, setProximity] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!buttonRef.current || !cursorPosition) {
|
||||
setProximity(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const dotRect = buttonRef.current.getBoundingClientRect();
|
||||
const labelRect = labelRef.current?.getBoundingClientRect();
|
||||
|
||||
let combinedRect: { left: number; right: number; top: number; bottom: number };
|
||||
|
||||
if (labelRect && labelRect.width > 0) {
|
||||
combinedRect = {
|
||||
left: Math.min(dotRect.left, labelRect.left),
|
||||
right: Math.max(dotRect.right, labelRect.right),
|
||||
top: Math.min(dotRect.top, labelRect.top),
|
||||
bottom: Math.max(dotRect.bottom, labelRect.bottom),
|
||||
};
|
||||
} else {
|
||||
combinedRect = dotRect;
|
||||
}
|
||||
|
||||
const dx = Math.max(
|
||||
combinedRect.left - cursorPosition.x,
|
||||
0,
|
||||
cursorPosition.x - combinedRect.right,
|
||||
);
|
||||
const dy = Math.max(
|
||||
combinedRect.top - cursorPosition.y,
|
||||
0,
|
||||
cursorPosition.y - combinedRect.bottom,
|
||||
);
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const proximity = Math.max(0, 1 - distance / PROXIMITY_THRESHOLD);
|
||||
setProximity(proximity);
|
||||
}, [cursorPosition]);
|
||||
|
||||
const showLabel = proximity > 0.1;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<m.button
|
||||
ref={labelRef}
|
||||
type="button"
|
||||
onClick={(e) => onDotClick(sectionId, e)}
|
||||
className="absolute right-full mr-3 cursor-pointer whitespace-nowrap font-body text-foreground text-sm"
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{
|
||||
opacity: shouldReduceMotion ? (showLabel ? 1 : 0) : proximity,
|
||||
x: shouldReduceMotion ? 0 : (1 - proximity) * 10,
|
||||
}}
|
||||
transition={springSnappy}
|
||||
>
|
||||
{SECTION_LABELS[sectionId]}
|
||||
</m.button>
|
||||
|
||||
<m.button
|
||||
ref={(el) => {
|
||||
buttonRef.current = el;
|
||||
magneticRef.current = el;
|
||||
}}
|
||||
type="button"
|
||||
aria-label={`Navigate to ${SECTION_LABELS[sectionId]} section`}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
className="relative flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
style={magneticStyle}
|
||||
onClick={(e) => onDotClick(sectionId, e)}
|
||||
{...handlers}
|
||||
>
|
||||
<m.span
|
||||
className={`block rounded-full ${isActive ? "bg-accent-cyan" : "bg-foreground/40"}`}
|
||||
animate={{ scale: isActive ? 1.4 : 1 }}
|
||||
transition={springSnappy}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
}}
|
||||
/>
|
||||
</m.button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { RadialReveal } from "@/components/scroll-progress-indicator/radial-reveal";
|
||||
import { SECTION_IDS, type SectionId, useActiveSection } from "@/hooks/use-active-section";
|
||||
import { Dot, PROXIMITY_THRESHOLD } from "./dot";
|
||||
|
||||
export const ScrollProgressIndicator = () => {
|
||||
const activeSection = useActiveSection();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [transition, setTransition] = useState<{
|
||||
targetSection: SectionId;
|
||||
origin: { x: number; y: number };
|
||||
} | null>(null);
|
||||
|
||||
const displayedActiveSection = transition?.targetSection ?? activeSection;
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const extendedThreshold = PROXIMITY_THRESHOLD * 2;
|
||||
const isNearContainer =
|
||||
e.clientX >= rect.left - extendedThreshold &&
|
||||
e.clientX <= rect.right + extendedThreshold &&
|
||||
e.clientY >= rect.top - extendedThreshold &&
|
||||
e.clientY <= rect.bottom + extendedThreshold;
|
||||
|
||||
if (isNearContainer) {
|
||||
setCursorPosition({ x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
setCursorPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
ref={containerRef}
|
||||
aria-label="Page sections"
|
||||
className="-translate-y-1/2 fixed top-1/2 right-4 z-100 hidden flex-col gap-3 md:flex"
|
||||
>
|
||||
{SECTION_IDS.map((sectionId) => (
|
||||
<Dot
|
||||
key={sectionId}
|
||||
sectionId={sectionId}
|
||||
isActive={displayedActiveSection === sectionId}
|
||||
cursorPosition={cursorPosition}
|
||||
onDotClick={(sectionId, event) => {
|
||||
if (shouldReduceMotion) {
|
||||
const targetElement = document.getElementById(sectionId);
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop,
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setTransition({
|
||||
targetSection: sectionId,
|
||||
origin: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{transition && (
|
||||
<RadialReveal
|
||||
targetSection={transition.targetSection}
|
||||
origin={transition.origin}
|
||||
onComplete={() => setTransition(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { m } from "motion/react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import type { SectionId } from "@/hooks/use-active-section";
|
||||
|
||||
type Props = {
|
||||
targetSection: SectionId;
|
||||
origin: { x: number; y: number };
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export const RadialReveal = ({ targetSection, origin, onComplete }: Props) => {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const [targetScrollY, setTargetScrollY] = useState(0);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasCompleted, setHasCompleted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const targetElement = document.getElementById(targetSection);
|
||||
const mainContent = document.querySelector("main");
|
||||
if (!targetElement || !mainContent || !overlayRef.current) return;
|
||||
|
||||
// Clamp to max scrollable position for short sections that can't reach viewport top
|
||||
const targetTop = targetElement.offsetTop;
|
||||
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const clampedScrollY = Math.min(targetTop, Math.max(0, maxScrollY));
|
||||
setTargetScrollY(clampedScrollY);
|
||||
|
||||
const clone = mainContent.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "absolute";
|
||||
clone.style.top = "0";
|
||||
clone.style.left = "0";
|
||||
clone.style.width = "100%";
|
||||
clone.style.transform = `translateY(-${clampedScrollY}px)`;
|
||||
clone.style.pointerEvents = "none";
|
||||
|
||||
overlayRef.current.innerHTML = "";
|
||||
overlayRef.current.appendChild(clone);
|
||||
setIsReady(true);
|
||||
}, [targetSection]);
|
||||
|
||||
const maxRadius =
|
||||
typeof window !== "undefined"
|
||||
? Math.hypot(
|
||||
Math.max(origin.x, window.innerWidth - origin.x),
|
||||
Math.max(origin.y, window.innerHeight - origin.y),
|
||||
)
|
||||
: 2000;
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (hasCompleted) return;
|
||||
setHasCompleted(true);
|
||||
window.scrollTo({ top: targetScrollY, behavior: "instant" });
|
||||
requestAnimationFrame(() => onComplete());
|
||||
};
|
||||
|
||||
const finalRadius = maxRadius * 1.1;
|
||||
const borderWidth = 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<m.div
|
||||
className="fixed inset-0 z-98 bg-accent-cyan"
|
||||
initial={{ clipPath: `circle(0px at ${origin.x}px ${origin.y}px)` }}
|
||||
animate={
|
||||
isReady
|
||||
? { clipPath: `circle(${finalRadius + borderWidth}px at ${origin.x}px ${origin.y}px)` }
|
||||
: { clipPath: `circle(0px at ${origin.x}px ${origin.y}px)` }
|
||||
}
|
||||
transition={{ duration: 1 }}
|
||||
/>
|
||||
|
||||
<m.div
|
||||
className="fixed inset-0 z-99 overflow-hidden"
|
||||
initial={{ clipPath: `circle(0px at ${origin.x}px ${origin.y}px)` }}
|
||||
animate={
|
||||
isReady
|
||||
? { clipPath: `circle(${finalRadius}px at ${origin.x}px ${origin.y}px)` }
|
||||
: { clipPath: `circle(0px at ${origin.x}px ${origin.y}px)` }
|
||||
}
|
||||
transition={{ duration: 1 }}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="relative h-screen w-full overflow-hidden bg-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</m.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const SECTION_IDS = [
|
||||
"landing",
|
||||
"about",
|
||||
"projects",
|
||||
"travel",
|
||||
"photography",
|
||||
"footer",
|
||||
] as const;
|
||||
|
||||
export type SectionId = (typeof SECTION_IDS)[number];
|
||||
|
||||
export const SECTION_LABELS: Record<SectionId, string> = {
|
||||
landing: "Landing",
|
||||
about: "About",
|
||||
projects: "Projects",
|
||||
travel: "Travel",
|
||||
photography: "Photography",
|
||||
footer: "Footer",
|
||||
};
|
||||
|
||||
export const useActiveSection = (): SectionId => {
|
||||
const [activeSection, setActiveSection] = useState<SectionId>("landing");
|
||||
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = [];
|
||||
|
||||
for (const sectionId of SECTION_IDS) {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (!element) continue;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(sectionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// -50% top and bottom = detect when section crosses viewport center
|
||||
rootMargin: "-50% 0px -50% 0px",
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
observers.push(observer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const observer of observers) {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return activeSection;
|
||||
};
|
||||
Reference in New Issue
Block a user