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:
+51
-18
@@ -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 & 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 & 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user