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
This commit is contained in:
2026-02-10 16:25:14 +00:00
parent 80e4efbcb2
commit 3eb91a3cec
4 changed files with 190 additions and 18 deletions
+51 -18
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { Phase } from './types' import type { Phase } from './types'
import { BootSequence } from './components/BootSequence' import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation' import { ECGAnimation } from './components/ECGAnimation'
import { FloatingNav } from './components/FloatingNav'
function App() { function App() {
const [phase, setPhase] = useState<Phase>('boot') const [phase, setPhase] = useState<Phase>('boot')
@@ -17,24 +18,56 @@ function App() {
)} )}
{phase === 'content' && ( {phase === 'content' && (
<main className="max-w-[1000px] mx-auto px-8"> <>
<section className="min-h-screen flex flex-col justify-center items-center text-center py-20"> <FloatingNav />
<h1 className="font-primary font-bold text-5xl text-heading">Andy Charlwood</h1> <main className="max-w-[1000px] mx-auto px-8">
<p className="text-muted mt-2">Deputy Head of Population Health &amp; Data Analysis</p> <section id="about" className="min-h-screen flex flex-col justify-center items-center text-center py-20">
<span className="inline-block mt-2 px-4 py-1 border border-teal rounded-full text-xs text-teal font-medium"> <h1 className="font-primary font-bold text-5xl text-heading">Andy Charlwood</h1>
Norwich, UK <p className="text-muted mt-2">Deputy Head of Population Health &amp; Data Analysis</p>
</span> <span className="inline-block mt-2 px-4 py-1 border border-teal rounded-full text-xs text-teal font-medium">
<p className="mt-6 max-w-[560px] text-text"> Norwich, UK
GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes. </span>
</p> <p className="mt-6 max-w-[560px] text-text">
</section> GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes.
</p>
<section className="py-20"> </section>
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Components will be built in subsequent tasks <section id="skills" className="py-20">
</h2> <h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
</section> Skills
</main> </h2>
<p className="text-muted text-center">Skills section will be built in Task 7</p>
</section>
<section id="experience" className="py-20">
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Experience
</h2>
<p className="text-muted text-center">Experience section will be built in Task 8</p>
</section>
<section id="education" className="py-20">
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Education
</h2>
<p className="text-muted text-center">Education section will be built in Task 9</p>
</section>
<section id="projects" className="py-20">
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Projects
</h2>
<p className="text-muted text-center">Projects section will be built in Task 9</p>
</section>
<section id="contact" className="py-20">
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Contact
</h2>
<p className="text-muted text-center">Contact section will be built in Task 9</p>
</section>
</main>
</>
)} )}
</div> </div>
) )
+68
View File
@@ -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 (
<motion.nav
className="fixed top-4 left-1/2 -translate-x-1/2 z-[100] max-w-[600px] w-auto bg-white rounded-full py-2 px-6 shadow-md flex items-center gap-1 border border-border overflow-x-auto scrollbar-hide"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
{navLinks.map((link) => {
const isActive = activeSection === link.id
return (
<button
key={link.id}
onClick={() => scrollToSection(link.id)}
className={`
relative font-secondary text-[13px] font-medium py-1.5 px-3.5 rounded-full
transition-colors duration-300 ease-out whitespace-nowrap
${isActive
? 'text-teal font-semibold'
: 'text-muted hover:text-teal hover:bg-teal-light'
}
`}
>
{link.label}
{isActive && (
<motion.span
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-teal"
layoutId="navIndicator"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
/>
)}
</button>
)
})}
</motion.nav>
)
}
+58
View File
@@ -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<SectionId>('about')
const observerRef = useRef<IntersectionObserver | null>(null)
const visibleSectionsRef = useRef<Map<string, number>>(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
}
+13
View File
@@ -65,3 +65,16 @@ body {
.animate-seed-pulse { .animate-seed-pulse {
animation: seedPulse 0.6s ease-in-out infinite; 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;
}