feat: Build Skills section with SVG circular gauges

- Create Skills.tsx with three categories (Technical, Clinical, Strategic)
- Add SkillGauge component with animated circular SVG progress
- Create useScrollReveal.ts hook for IntersectionObserver-based animations
- Staggered gauge fill animations (100ms delay per skill)
- Exact 18 skills with percentages from concept.html
- Hover effects matching concept.html styling
This commit is contained in:
2026-02-10 16:39:39 +00:00
parent 293e292698
commit 0ffc71f110
3 changed files with 237 additions and 6 deletions
+2 -6
View File
@@ -4,6 +4,7 @@ import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation'
import { FloatingNav } from './components/FloatingNav'
import { Hero } from './components/Hero'
import { Skills } from './components/Skills'
function App() {
const [phase, setPhase] = useState<Phase>('boot')
@@ -24,12 +25,7 @@ function App() {
<main className="max-w-[1000px] mx-auto px-8">
<Hero />
<section id="skills" className="py-20">
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Skills
</h2>
<p className="text-muted text-center">Skills section will be built in Task 7</p>
</section>
<Skills />
<section id="experience" className="py-20">
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
+195
View File
@@ -0,0 +1,195 @@
import { useRef, useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import type { Skill } from '../types'
import { calculateSkillOffset } from '../lib/utils'
const GAUGE_RADIUS = 34
const GAUGE_CIRCUMFERENCE = 2 * Math.PI * GAUGE_RADIUS
interface SkillGaugeProps {
skill: Skill
delay: number
isVisible: boolean
}
function SkillGauge({ skill, delay, isVisible }: SkillGaugeProps) {
const [animated, setAnimated] = useState(false)
const strokeColor = skill.color === 'coral' ? '#FF6B6B' : '#00897B'
const hoverBg = skill.color === 'coral' ? 'hover:bg-coral-light' : 'hover:bg-teal-light'
const targetOffset = calculateSkillOffset(skill.level, GAUGE_RADIUS)
useEffect(() => {
if (isVisible && !animated) {
const timer = setTimeout(() => setAnimated(true), delay)
return () => clearTimeout(timer)
}
}, [isVisible, animated, delay])
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay: delay / 1000, ease: 'easeOut' }}
className={`flex flex-col items-center p-4 rounded-2xl transition-colors duration-300 ${hoverBg}`}
>
<svg
className="skill-gauge block"
width="80"
height="80"
viewBox="0 0 80 80"
>
<circle
cx="40"
cy="40"
r={GAUGE_RADIUS}
fill="none"
stroke="#E2E8F0"
strokeWidth="5"
/>
<circle
cx="40"
cy="40"
r={GAUGE_RADIUS}
fill="none"
stroke={strokeColor}
strokeWidth="5"
strokeLinecap="round"
transform="rotate(-90, 40, 40)"
style={{
strokeDasharray: GAUGE_CIRCUMFERENCE,
strokeDashoffset: animated ? targetOffset : GAUGE_CIRCUMFERENCE,
transition: animated ? 'stroke-dashoffset 1.2s ease-out' : 'none'
}}
/>
<text
x="40"
y="40"
textAnchor="middle"
dominantBaseline="central"
fontSize="14"
fontWeight="600"
fill="#0F172A"
fontFamily="'Inter Tight', system-ui, sans-serif"
>
{skill.level}%
</text>
</svg>
<span className="font-primary text-xs font-semibold text-heading mt-2 text-center leading-tight">
{skill.name}
</span>
<span className="font-secondary text-[10px] text-muted uppercase tracking-wide mt-0.5">
{skill.category}
</span>
</motion.div>
)
}
interface SkillCategoryProps {
label: string
skills: Skill[]
isVisible: boolean
baseDelay: number
}
function SkillCategory({ label, skills, isVisible, baseDelay }: SkillCategoryProps) {
return (
<div className="mb-10 last:mb-0">
<h3 className="font-secondary text-xs font-semibold uppercase tracking-widest text-muted mb-5 pl-1">
{label}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-6">
{skills.map((skill, index) => (
<SkillGauge
key={skill.name}
skill={skill}
delay={baseDelay + index * 100}
isVisible={isVisible}
/>
))}
</div>
</div>
)
}
const skillsData: Skill[] = [
{ name: 'Python', level: 90, category: 'Technical', color: 'teal' },
{ name: 'SQL', level: 88, category: 'Technical', color: 'teal' },
{ name: 'Power BI', level: 92, category: 'Technical', color: 'teal' },
{ name: 'JS / TS', level: 70, category: 'Technical', color: 'teal' },
{ name: 'Data Analysis', level: 95, category: 'Technical', color: 'teal' },
{ name: 'Dashboard Dev', level: 88, category: 'Technical', color: 'teal' },
{ name: 'Algorithm Design', level: 82, category: 'Technical', color: 'teal' },
{ name: 'Data Pipelines', level: 80, category: 'Technical', color: 'teal' },
{ name: 'Medicines Optimisation', level: 95, category: 'Clinical', color: 'coral' },
{ name: 'Pop. Health Analytics', level: 90, category: 'Clinical', color: 'coral' },
{ name: 'NICE TA', level: 85, category: 'Clinical', color: 'coral' },
{ name: 'Health Economics', level: 80, category: 'Clinical', color: 'coral' },
{ name: 'Clinical Pathways', level: 82, category: 'Clinical', color: 'coral' },
{ name: 'CD Assurance', level: 88, category: 'Clinical', color: 'coral' },
{ name: 'Budget Mgmt', level: 90, category: 'Strategic', color: 'teal' },
{ name: 'Stakeholder Engagement', level: 88, category: 'Strategic', color: 'teal' },
{ name: 'Pharma Negotiation', level: 85, category: 'Strategic', color: 'teal' },
{ name: 'Team Development', level: 82, category: 'Strategic', color: 'teal' },
]
export function Skills() {
const sectionRef = useRef<HTMLElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const element = sectionRef.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.unobserve(element)
}
},
{ threshold: 0.15, rootMargin: '0px' }
)
observer.observe(element)
return () => observer.disconnect()
}, [])
const technicalSkills = skillsData.filter(s => s.category === 'Technical')
const clinicalSkills = skillsData.filter(s => s.category === 'Clinical')
const strategicSkills = skillsData.filter(s => s.category === 'Strategic')
return (
<section id="skills" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Skills &amp; Expertise
</motion.h2>
<SkillCategory
label="Technical"
skills={technicalSkills}
isVisible={isVisible}
baseDelay={200}
/>
<SkillCategory
label="Clinical"
skills={clinicalSkills}
isVisible={isVisible}
baseDelay={200 + technicalSkills.length * 100 + 100}
/>
<SkillCategory
label="Strategic"
skills={strategicSkills}
isVisible={isVisible}
baseDelay={200 + technicalSkills.length * 100 + clinicalSkills.length * 100 + 200}
/>
</section>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
interface UseScrollRevealOptions {
threshold?: number
rootMargin?: string
triggerOnce?: boolean
}
export function useScrollReveal<T extends HTMLElement>(
options: UseScrollRevealOptions = {}
): [RefObject<T | null>, boolean] {
const { threshold = 0.15, rootMargin = '0px', triggerOnce = true } = options
const ref = useRef<T | null>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
if (triggerOnce) {
observer.unobserve(element)
}
} else if (!triggerOnce) {
setIsVisible(false)
}
},
{ threshold, rootMargin }
)
observer.observe(element)
return () => observer.disconnect()
}, [threshold, rootMargin, triggerOnce])
return [ref, isVisible]
}