From 3eb91a3cecc1b235b187bdf2e5d0f1352625600d Mon Sep 17 00:00:00 2001 From: A Charlwood Date: Tue, 10 Feb 2026 16:25:14 +0000 Subject: [PATCH] Task 5: Build FloatingNav component with active section tracking - Created useActiveSection hook using IntersectionObserver for scroll tracking - Built FloatingNav with Framer Motion animated indicator dot - Added section IDs to App.tsx for scroll targets - Added scrollbar-hide utility and smooth scroll to index.css --- src/App.tsx | 69 +++++++++++++++++++++++++--------- src/components/FloatingNav.tsx | 68 +++++++++++++++++++++++++++++++++ src/hooks/useActiveSection.ts | 58 ++++++++++++++++++++++++++++ src/index.css | 13 +++++++ 4 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 src/components/FloatingNav.tsx create mode 100644 src/hooks/useActiveSection.ts diff --git a/src/App.tsx b/src/App.tsx index 8f4229b..f435334 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { Phase } from './types' import { BootSequence } from './components/BootSequence' import { ECGAnimation } from './components/ECGAnimation' +import { FloatingNav } from './components/FloatingNav' function App() { const [phase, setPhase] = useState('boot') @@ -17,24 +18,56 @@ function App() { )} {phase === 'content' && ( -
-
-

Andy Charlwood

-

Deputy Head of Population Health & Data Analysis

- - Norwich, UK - -

- GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes. -

-
- -
-

- Components will be built in subsequent tasks -

-
-
+ <> + +
+
+

Andy Charlwood

+

Deputy Head of Population Health & Data Analysis

+ + Norwich, UK + +

+ GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes. +

+
+ +
+

+ Skills +

+

Skills section will be built in Task 7

+
+ +
+

+ Experience +

+

Experience section will be built in Task 8

+
+ +
+

+ Education +

+

Education section will be built in Task 9

+
+ +
+

+ Projects +

+

Projects section will be built in Task 9

+
+ +
+

+ Contact +

+

Contact section will be built in Task 9

+
+
+ )} ) diff --git a/src/components/FloatingNav.tsx b/src/components/FloatingNav.tsx new file mode 100644 index 0000000..10236fa --- /dev/null +++ b/src/components/FloatingNav.tsx @@ -0,0 +1,68 @@ +import { useCallback } from 'react' +import { motion } from 'framer-motion' +import { useActiveSection } from '@/hooks/useActiveSection' + +interface NavLink { + id: string + label: string +} + +const navLinks: NavLink[] = [ + { id: 'about', label: 'About' }, + { id: 'skills', label: 'Skills' }, + { id: 'experience', label: 'Experience' }, + { id: 'education', label: 'Education' }, + { id: 'projects', label: 'Projects' }, + { id: 'contact', label: 'Contact' }, +] + +export function FloatingNav() { + const activeSection = useActiveSection() + + const scrollToSection = useCallback((sectionId: string) => { + const element = document.getElementById(sectionId) + if (element) { + element.scrollIntoView({ behavior: 'smooth' }) + } + }, []) + + return ( + + {navLinks.map((link) => { + const isActive = activeSection === link.id + + return ( + + ) + })} + + ) +} diff --git a/src/hooks/useActiveSection.ts b/src/hooks/useActiveSection.ts new file mode 100644 index 0000000..e371cd1 --- /dev/null +++ b/src/hooks/useActiveSection.ts @@ -0,0 +1,58 @@ +import { useEffect, useState, useRef, useCallback } from 'react' + +const SECTION_IDS = ['about', 'skills', 'experience', 'education', 'projects', 'contact'] as const + +type SectionId = typeof SECTION_IDS[number] + +export function useActiveSection(): SectionId { + const [activeSection, setActiveSection] = useState('about') + const observerRef = useRef(null) + const visibleSectionsRef = useRef>(new Map()) + + const handleIntersect = useCallback((entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + const sectionId = entry.target.id + if (SECTION_IDS.includes(sectionId as SectionId)) { + if (entry.isIntersecting) { + visibleSectionsRef.current.set(sectionId, entry.intersectionRatio) + } else { + visibleSectionsRef.current.delete(sectionId) + } + } + }) + + const visibleEntries = Array.from(visibleSectionsRef.current.entries()) + if (visibleEntries.length > 0) { + visibleEntries.sort((a, b) => { + const indexA = SECTION_IDS.indexOf(a[0] as SectionId) + const indexB = SECTION_IDS.indexOf(b[0] as SectionId) + return indexA - indexB + }) + + const topSection = visibleEntries[0][0] as SectionId + setActiveSection(topSection) + } + }, []) + + useEffect(() => { + observerRef.current = new IntersectionObserver(handleIntersect, { + rootMargin: '-20% 0px -70% 0px', + threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1], + }) + + SECTION_IDS.forEach((id) => { + const element = document.getElementById(id) + if (element && observerRef.current) { + observerRef.current.observe(element) + } + }) + + return () => { + if (observerRef.current) { + observerRef.current.disconnect() + } + } + }, [handleIntersect]) + + return activeSection +} diff --git a/src/index.css b/src/index.css index 53920a7..39f5a48 100644 --- a/src/index.css +++ b/src/index.css @@ -65,3 +65,16 @@ body { .animate-seed-pulse { animation: seedPulse 0.6s ease-in-out infinite; } + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +html { + scroll-behavior: smooth; +}