diff --git a/src/app/page.tsx b/src/app/page.tsx index 3003db5..c957622 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,10 +6,13 @@ import { SkillsSection } from '@/components/SkillsSection'; import { EducationSection } from '@/components/EducationSection'; import { MilestonesSection } from '@/components/MilestonesSection'; import { FooterSection } from '@/components/FooterSection'; +import { MotionController } from '@/components/MotionController'; export default function Home() { return (
+ + {/* 1. Hero & About Me River Journey (Seamless Scenic Background) */} diff --git a/src/components/EducationSection.module.css b/src/components/EducationSection.module.css index b0c123b..2fda710 100644 --- a/src/components/EducationSection.module.css +++ b/src/components/EducationSection.module.css @@ -42,6 +42,7 @@ object-position: left top; width: 100%; height: 100%; + transition: transform var(--motion-duration-default) var(--motion-ease-out); } .institution { @@ -121,4 +122,8 @@ border-color: var(--accent-default); transform: translateY(-2px); } + + .card:hover .crestImage { + transform: scale(1.025); + } } diff --git a/src/components/EducationSection.tsx b/src/components/EducationSection.tsx index ee7c166..10d3103 100644 --- a/src/components/EducationSection.tsx +++ b/src/components/EducationSection.tsx @@ -7,7 +7,7 @@ export const EducationSection: React.FC = () => { return (
-
+

Where I Built the Foundation

@@ -15,8 +15,8 @@ export const EducationSection: React.FC = () => {
{/* Degree Card */} -
-
+
+
{
-
+

{education.degree}

{education.institution}
{education.date}
diff --git a/src/components/ExperienceSection.module.css b/src/components/ExperienceSection.module.css index 84173b7..8fa3e15 100644 --- a/src/components/ExperienceSection.module.css +++ b/src/components/ExperienceSection.module.css @@ -28,6 +28,7 @@ } .nodeDot { + position: relative; width: 0.875rem; height: 0.875rem; border-radius: 50%; @@ -44,6 +45,28 @@ box-shadow: 0 0 0 4px rgba(61, 138, 250, 0.25); } +.nodeCurrent::after { + content: ''; + position: absolute; + inset: -0.375rem; + border: 1px solid var(--accent-default); + border-radius: 50%; + opacity: 0; + animation: currentRolePulse 2.4s var(--motion-ease-out) infinite; +} + +@keyframes currentRolePulse { + 0% { + opacity: 0.45; + transform: scale(0.7); + } + 70%, + 100% { + opacity: 0; + transform: scale(1.35); + } +} + .verticalLine { position: absolute; top: 1.25rem; @@ -51,6 +74,16 @@ width: 2px; background-color: var(--border-strong); z-index: 1; + transform-origin: top center; +} + +:global(html[data-motion-ready='true']) .timelineItem[data-reveal] .verticalLine { + transform: scaleY(0); + transition: transform 600ms var(--motion-ease-in-out) 120ms; +} + +:global(html[data-motion-ready='true']) .timelineItem[data-visible='true'] .verticalLine { + transform: scaleY(1); } .itemContent { @@ -61,6 +94,16 @@ gap: 2.25rem; } +@media (prefers-reduced-motion: reduce) { + .nodeCurrent::after { + animation: none; + } + + :global(html[data-motion-ready='true']) .timelineItem[data-reveal] .verticalLine { + transform: scaleY(1); + } +} + .identityCol { width: 22.5rem; flex-shrink: 0; diff --git a/src/components/ExperienceSection.tsx b/src/components/ExperienceSection.tsx index 0682dfe..3bc8676 100644 --- a/src/components/ExperienceSection.tsx +++ b/src/components/ExperienceSection.tsx @@ -7,7 +7,7 @@ export const ExperienceSection: React.FC = () => { return (
-
+

Where I've Made an Impact

@@ -15,9 +15,13 @@ export const ExperienceSection: React.FC = () => {
{/* Timeline Container */} -
+
{experiences.map((item, index) => ( -
+
{/* Vertical Spine & Node Indicator */}
{
{/* Content */} -
+

Let's build something meaningful.

diff --git a/src/components/HeroSection.module.css b/src/components/HeroSection.module.css index b93d05a..05178fe 100644 --- a/src/components/HeroSection.module.css +++ b/src/components/HeroSection.module.css @@ -56,6 +56,7 @@ .controlIcon, .themeIcon { color: currentColor; + transition: transform var(--motion-duration-default) var(--motion-ease-out); } .themeIcon { @@ -156,6 +157,41 @@ margin-bottom: clamp(1.125rem, 2.2vw, 1.875rem); } +.topBar, +.avatarWrapper, +.heroTitle, +.heroSubtitle, +.actionButtons { + animation: heroContentEnter var(--motion-duration-hero) var(--motion-ease-out) both; +} + +.avatarWrapper { + animation-delay: var(--motion-stagger); +} + +.heroTitle { + animation-delay: 100ms; +} + +.heroSubtitle { + animation-delay: 150ms; +} + +.actionButtons { + animation-delay: 200ms; +} + +@keyframes heroContentEnter { + from { + opacity: 0; + transform: translateY(0.75rem) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + .heroButton { min-width: clamp(5rem, 9vw, 7.5rem); height: clamp(2.5rem, 4.5vw, 3.25rem); @@ -352,6 +388,11 @@ transform: translateY(-1px); } + .iconButton:hover .controlIcon, + .iconButton:hover .themeIcon { + transform: rotate(12deg) scale(1.04); + } + .contactItem:hover { opacity: 0.75; } @@ -380,3 +421,21 @@ background: var(--color-blue-white); } } + +@media (prefers-reduced-motion: reduce) { + .topBar, + .avatarWrapper, + .heroTitle, + .heroSubtitle, + .actionButtons { + animation-name: heroContentFade; + animation-duration: var(--motion-duration-default) !important; + animation-delay: 0ms; + transform: none; + } + + @keyframes heroContentFade { + from { opacity: 0; } + to { opacity: 1; } + } +} diff --git a/src/components/MilestonesSection.module.css b/src/components/MilestonesSection.module.css index 30b8ea0..69246d1 100644 --- a/src/components/MilestonesSection.module.css +++ b/src/components/MilestonesSection.module.css @@ -105,6 +105,10 @@ min-height: 2.75rem; } +.viewCertLink svg { + transition: transform var(--motion-duration-fast) var(--motion-ease-out); +} + [data-theme="dark"] .certCard { background-color: var(--bg-raised); } @@ -145,4 +149,8 @@ .viewCertLink:hover { gap: 0.625rem; } + + .viewCertLink:hover svg { + transform: translate(2px, -2px); + } } diff --git a/src/components/MilestonesSection.tsx b/src/components/MilestonesSection.tsx index a565a29..b1a7692 100644 --- a/src/components/MilestonesSection.tsx +++ b/src/components/MilestonesSection.tsx @@ -11,7 +11,7 @@ export const MilestonesSection: React.FC = () => {
{/* Section Header */} -
+
AWARDS & ACHIEVEMENTS

Milestones Along the Way

@@ -20,11 +20,11 @@ export const MilestonesSection: React.FC = () => {
{/* Certificate Cards List */} -
+
{milestones.map((item) => (
{/* Thumbnail */} -
+
{item.title} {
{/* Content Details */} -
+

{item.title}

diff --git a/src/components/MotionController.tsx b/src/components/MotionController.tsx new file mode 100644 index 0000000..3bbf8e5 --- /dev/null +++ b/src/components/MotionController.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useEffect } from 'react'; + +const REVEAL_SELECTOR = '[data-reveal]'; + +export const MotionController = () => { + useEffect(() => { + const elements = Array.from( + document.querySelectorAll(REVEAL_SELECTOR), + ); + + if (!elements.length) return; + + const reduceMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + + document.querySelectorAll('[data-reveal-group]').forEach((group) => { + group.querySelectorAll(REVEAL_SELECTOR).forEach((element, index) => { + element.style.setProperty( + '--reveal-delay', + `${Math.min(index, 5) * 50}ms`, + ); + }); + }); + + if (reduceMotion) { + elements.forEach((element) => { + element.dataset.visible = 'true'; + }); + document.documentElement.dataset.motionReady = 'true'; + return () => { + delete document.documentElement.dataset.motionReady; + }; + } + + const initialRevealBoundary = window.innerHeight * 0.92; + elements.forEach((element) => { + const bounds = element.getBoundingClientRect(); + if (bounds.top <= initialRevealBoundary && bounds.bottom >= 0) { + element.dataset.visible = 'true'; + } + }); + + document.documentElement.dataset.motionReady = 'true'; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + + const element = entry.target as HTMLElement; + element.dataset.visible = 'true'; + observer.unobserve(element); + }); + }, + { + rootMargin: '0px 0px -8% 0px', + threshold: 0.12, + }, + ); + + elements.forEach((element) => { + if (element.dataset.visible !== 'true') observer.observe(element); + }); + + return () => { + observer.disconnect(); + delete document.documentElement.dataset.motionReady; + }; + }, []); + + return null; +}; diff --git a/src/components/ProjectsSection.module.css b/src/components/ProjectsSection.module.css index 7825b37..b27ef3c 100644 --- a/src/components/ProjectsSection.module.css +++ b/src/components/ProjectsSection.module.css @@ -41,6 +41,10 @@ color: var(--color-blue-600); } +.navButton svg { + transition: transform var(--motion-duration-fast) var(--motion-ease-out); +} + .navButton:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 3px; @@ -164,6 +168,7 @@ .titleLinkIcon { flex: 0 0 auto; color: var(--color-blue-500); + transition: transform var(--motion-duration-fast) var(--motion-ease-out); } [data-theme="dark"] .titleLinkIcon { @@ -233,6 +238,10 @@ min-height: 2.75rem; } +.caseStudyLink svg { + transition: transform var(--motion-duration-fast) var(--motion-ease-out); +} + .caseStudyLink:focus-visible { border-radius: 2px; outline: 3px solid var(--focus-ring); @@ -474,7 +483,11 @@ } .card:hover .projectImage { - transform: scale(1.02); + transform: scale(1.035); + } + + .card:hover .titleLinkIcon { + transform: translate(2px, -2px); } .caseStudyLink:hover { @@ -491,4 +504,17 @@ background: var(--color-blue-white); color: var(--color-blue-950); } + + + .caseStudyLink:hover svg { + transform: translateX(3px); + } + + .navButtonPrevious:hover:not(:disabled) svg { + transform: translateX(-2px); + } + + .navButtonNext:hover:not(:disabled) svg { + transform: translateX(2px); + } } diff --git a/src/components/ProjectsSection.tsx b/src/components/ProjectsSection.tsx index f116455..ec5ffc1 100644 --- a/src/components/ProjectsSection.tsx +++ b/src/components/ProjectsSection.tsx @@ -203,7 +203,7 @@ export const ProjectsSection: React.FC = () => { aria-labelledby="projects-title" >
-
+
PROJECTS
@@ -251,10 +251,10 @@ export const ProjectsSection: React.FC = () => { aria-label="Scrollable project cards" aria-roledescription="carousel" > -
+
{projects.map((project) => (
-
+
{ />
-
+

{project.title}

{project.link && ( @@ -299,7 +299,7 @@ export const ProjectsSection: React.FC = () => {
-
+
{
{/* Intro Header & Description on the Left */} -
+
ABOUT ME

From Pixel to Product

@@ -36,12 +36,16 @@ export const RiverJourney: React.FC = () => {

-
    +
      {steps.map((step) => { const StepIcon = step.icon; return ( -
    1. +
    2. diff --git a/src/components/ScenicHeroJourney.module.css b/src/components/ScenicHeroJourney.module.css index 1fe282f..3e211d9 100644 --- a/src/components/ScenicHeroJourney.module.css +++ b/src/components/ScenicHeroJourney.module.css @@ -97,12 +97,22 @@ top: clamp(72px, 8vw, 100px); left: clamp(-220px, calc(22% - 28vw), -40px); width: clamp(240px, 28vw, 440px); + animation: landscapeFloatLeft 8s var(--motion-ease-in-out) infinite alternate; } .backdropRight { top: clamp(80px, 9vw, 110px); right: clamp(-240px, calc(22% - 29vw), -40px); width: clamp(250px, 29vw, 460px); + animation: landscapeFloatRight 9s var(--motion-ease-in-out) -2s infinite alternate; +} + +@keyframes landscapeFloatLeft { + to { transform: translate3d(0.5rem, -0.375rem, 0); } +} + +@keyframes landscapeFloatRight { + to { transform: translate3d(-0.5rem, 0.375rem, 0); } } .backdropSun, @@ -112,6 +122,14 @@ width: clamp(52px, 10vw, 139px); } +@media (prefers-reduced-motion: reduce) { + .backdropLeft, + .backdropRight { + animation: none; + transform: none; + } +} + /* ================================================================ TABLET 651px – 1100px Large illustrations with heavy bleed — bold fragments at edges. diff --git a/src/components/SkillsSection.module.css b/src/components/SkillsSection.module.css index cd08de3..dc25f24 100644 --- a/src/components/SkillsSection.module.css +++ b/src/components/SkillsSection.module.css @@ -67,6 +67,27 @@ .segmentFilled { background-color: var(--accent-default); + transform-origin: left center; +} + +.mainCard[data-visible='true'] .segmentFilled { + animation: meterSegmentIn 400ms var(--motion-ease-out) both; +} + +.segmentFilled:nth-child(2) { animation-delay: 40ms; } +.segmentFilled:nth-child(3) { animation-delay: 80ms; } +.segmentFilled:nth-child(4) { animation-delay: 120ms; } +.segmentFilled:nth-child(5) { animation-delay: 160ms; } + +@keyframes meterSegmentIn { + from { + opacity: 0.35; + transform: scaleX(0.2); + } + to { + opacity: 1; + transform: scaleX(1); + } } .badgeCloud { @@ -135,6 +156,7 @@ object-fit: contain; width: 100%; height: 100%; + transition: transform var(--motion-duration-default) var(--motion-ease-out); } .toolLabel { @@ -247,4 +269,20 @@ box-shadow: 0 8px 24px var(--shadow-color); transform: translateY(-3px); } + + .toolTile:hover .toolIcon { + transform: scale(1.06) translateY(-2px); + } +} + +@media (prefers-reduced-motion: reduce) { + .mainCard[data-visible='true'] .segmentFilled { + animation-name: meterSegmentFade; + transform: none; + } + + @keyframes meterSegmentFade { + from { opacity: 0.35; } + to { opacity: 1; } + } } diff --git a/src/components/SkillsSection.tsx b/src/components/SkillsSection.tsx index d189782..a8aa6b5 100644 --- a/src/components/SkillsSection.tsx +++ b/src/components/SkillsSection.tsx @@ -29,7 +29,7 @@ export const SkillsSection: React.FC = () => {
      {/* Section Header */} -
      +
      SKILLS & TOOLS

      The Stack Behind the Work...

      @@ -38,7 +38,7 @@ export const SkillsSection: React.FC = () => {
      {/* Main Card Container */} -
      +
      {/* Section: Skills */}
      SKILLS @@ -97,7 +97,11 @@ export const SkillsSection: React.FC = () => {
      {/* Filter Tabs Under Card */} -
      +
      {filterTabs.map((tab) => { const isActive = activeFilter === tab.id; return ( diff --git a/src/styles/globals.css b/src/styles/globals.css index 5142318..69153c0 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -119,6 +119,51 @@ body { height: 3px; background-color: var(--accent-default); border-radius: var(--radius-pill); + transform-origin: left center; +} + +/* One-time viewport reveals. Content remains visible when JavaScript is unavailable. */ +html[data-motion-ready='true'] [data-reveal] { + opacity: 0; + transform: translateY(1rem); + transition: + opacity var(--motion-duration-reveal) var(--motion-ease-out), + transform var(--motion-duration-reveal) var(--motion-ease-out); + transition-delay: var(--reveal-delay, 0ms); + will-change: opacity, transform; +} + +html[data-motion-ready='true'] [data-reveal='soft'] { + transform: translateY(0.625rem); +} + +html[data-motion-ready='true'] [data-reveal='scale'] { + transform: translateY(0.5rem) scale(0.97); +} + +html[data-motion-ready='true'] [data-reveal='image'] { + transform: translateY(0.5rem) scale(0.985); +} + +html[data-motion-ready='true'] [data-reveal][data-visible='true'] { + opacity: 1; + transform: translateY(0) scale(1); + will-change: auto; +} + +html[data-motion-ready='true'] [data-visible='true'] .section-underline { + animation: motionUnderline 500ms var(--motion-ease-out) 120ms both; +} + +@keyframes motionUnderline { + from { + opacity: 0; + transform: scaleX(0.25); + } + to { + opacity: 1; + transform: scaleX(1); + } } /* Pacifico Display Font Class */ diff --git a/src/styles/tokens.css b/src/styles/tokens.css index b012203..e0728ce 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -83,6 +83,9 @@ --letter-spacing-eyebrow: 0.16em; --motion-duration-fast: 140ms; --motion-duration-default: 200ms; + --motion-duration-reveal: 500ms; + --motion-duration-hero: 650ms; + --motion-stagger: 50ms; --motion-ease-out: cubic-bezier(0.23, 1, 0.32, 1); --motion-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);