Add scroll progress indicator

This commit is contained in:
2026-02-23 15:50:03 +00:00
parent 8fb5107881
commit c3fb5e98b9
5 changed files with 512 additions and 44 deletions
+145 -44
View File
@@ -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&apos;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&apos;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">
&quot;Motion is the medium. Animation is not decoration, it&apos;s the point.&quot;
</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>
</>
);
};
+62
View File
@@ -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;
};