diff --git a/src/app/page.tsx b/src/app/page.tsx index d1d47da..237dfbe 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,58 +1,159 @@ import { MagneticHoverShowcase } from "@/components/magnetic-hover-showcase"; +import { ScrollProgressIndicator } from "@/components/scroll-progress-indicator"; export default function Home() { return ( -
-
-
-

Space Grotesk

-

- 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. -

-

- And this is Caveat, for handwritten notes and journal entries... -

-
+ <> + -
-

Color Palette

-
-
-
-

Background

- oklch(0.16 0.02 255) +
+
+
+

Welcome

+

+ Scroll down to explore the sections and test the progress indicator. +

+

+ Watch the dots on the right... +

+
+
+ +
+
+
+

About

+

+ 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. +

+

+ And this is Caveat, for handwritten notes and journal entries... +

-
-
-

Foreground

- oklch(0.94 0.02 90) + +
+

Color Palette

+
+
+
+

Background

+ oklch(0.16 0.02 255) +
+
+
+

Foreground

+ oklch(0.94 0.02 90) +
+
+
+

Accent Cyan

+ oklch(0.75 0.14 195) +
+
+
+

Accent Violet

+ oklch(0.58 0.19 300) +
+
-
-
-

Accent Cyan

- oklch(0.75 0.14 195) + +
+

+ "Motion is the medium. Animation is not decoration, it's the point." +

+
+
+
+ +
+
+
+

Projects

+

+ Interactive showcase of the magnetic spring hover effect from Story 1.5. +

-
-
-

Accent Violet

- oklch(0.58 0.19 300) + + +
+
+ +
+
+
+

Travel

+

+ 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. +

+
+ +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ Travel Card {i + 1} +
+ ))} +
+ +
+

The journey continues...

-
+
-
-

Accent Colors in Use

-

- Links and highlights use{" "} - cyan for primary actions and{" "} - violet for secondary emphasis. -

-
+
+
+
+

Photography

+

+ A masonry gallery will be implemented here in Epic 6, featuring integration with + Immich for photo management. +

+
- -
-
+
+ {Array.from({ length: 9 }, (_, i) => ( +
+ Photo {i + 1} +
+ ))} +
+ + + + + + ); } diff --git a/src/components/scroll-progress-indicator/dot.tsx b/src/components/scroll-progress-indicator/dot.tsx new file mode 100644 index 0000000..21aa400 --- /dev/null +++ b/src/components/scroll-progress-indicator/dot.tsx @@ -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(null); + const labelRef = useRef(null); + + const { + ref: magneticRef, + style: magneticStyle, + handlers, + } = useMagneticSpringHover({ + 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 ( +
+ 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]} + + + { + 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} + > + + +
+ ); +}; diff --git a/src/components/scroll-progress-indicator/index.tsx b/src/components/scroll-progress-indicator/index.tsx new file mode 100644 index 0000000..fda7689 --- /dev/null +++ b/src/components/scroll-progress-indicator/index.tsx @@ -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(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 ( + <> + + + {transition && ( + setTransition(null)} + /> + )} + + ); +}; diff --git a/src/components/scroll-progress-indicator/radial-reveal.tsx b/src/components/scroll-progress-indicator/radial-reveal.tsx new file mode 100644 index 0000000..5bd1a67 --- /dev/null +++ b/src/components/scroll-progress-indicator/radial-reveal.tsx @@ -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(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 ( + <> + + + +