feat(motion): add scroll reveals and micro-interactions

This commit is contained in:
2026-09-07 00:19:07 +03:30
parent fa98f70b51
commit b0c9ad1296
20 changed files with 420 additions and 24 deletions

View File

@@ -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);
}
}

View File

@@ -7,7 +7,7 @@ export const EducationSection: React.FC = () => {
return (
<section id="education" className={styles.educationSection}>
<div className="content-container">
<div className="section-header">
<div className="section-header" data-reveal="soft">
<div className="section-title-wrap">
<h2 className="section-title">Where I Built the Foundation</h2>
<div className="section-underline" />
@@ -15,8 +15,8 @@ export const EducationSection: React.FC = () => {
</div>
{/* Degree Card */}
<div className={styles.card}>
<div className={styles.crestCol}>
<div className={styles.card} data-reveal-group>
<div className={styles.crestCol} data-reveal="image">
<div className={styles.crestContainer}>
<Image
src={education.logo}
@@ -28,7 +28,7 @@ export const EducationSection: React.FC = () => {
</div>
</div>
<div className={styles.detailsCol}>
<div className={styles.detailsCol} data-reveal="soft">
<h3 className={styles.degreeTitle}>{education.degree}</h3>
<div className={styles.institution}>{education.institution}</div>
<div className={styles.date}>{education.date}</div>

View File

@@ -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;

View File

@@ -7,7 +7,7 @@ export const ExperienceSection: React.FC = () => {
return (
<section id="experience" className={styles.experienceSection}>
<div className="content-container">
<div className="section-header">
<div className="section-header" data-reveal="soft">
<div className="section-title-wrap">
<h2 className="section-title">Where I&apos;ve Made an Impact</h2>
<div className="section-underline" />
@@ -15,9 +15,13 @@ export const ExperienceSection: React.FC = () => {
</div>
{/* Timeline Container */}
<div className={styles.timeline}>
<div className={styles.timeline} data-reveal-group>
{experiences.map((item, index) => (
<article key={item.id} className={styles.timelineItem}>
<article
key={item.id}
className={styles.timelineItem}
data-reveal="soft"
>
{/* Vertical Spine & Node Indicator */}
<div className={styles.spineColumn}>
<div

View File

@@ -23,6 +23,11 @@
.bgImage {
object-fit: cover;
object-position: bottom center;
animation: footerArtworkDrift 12s var(--motion-ease-in-out) infinite alternate;
}
@keyframes footerArtworkDrift {
to { transform: scale(1.025) translateY(0.25rem); }
}
.content {
@@ -89,6 +94,7 @@
justify-content: center;
width: 3rem;
height: 3rem;
transition: transform var(--motion-duration-default) var(--motion-ease-out);
}
.socialIcon svg {
@@ -174,4 +180,16 @@
color: var(--accent-default);
transform: translateY(-2px);
}
.socialItem:hover .socialIcon {
transform: translateY(-2px) scale(1.05);
}
}
@media (prefers-reduced-motion: reduce) {
.bgImage {
animation: none;
transform: none;
}
}

View File

@@ -53,7 +53,7 @@ export const FooterSection: React.FC = () => {
</div>
{/* Content */}
<div className={styles.content}>
<div className={styles.content} data-reveal="scale">
<h2 className={`font-display ${styles.ctaTitle}`}>
Let&apos;s build something meaningful.
</h2>

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -11,7 +11,7 @@ export const MilestonesSection: React.FC = () => {
<section id="milestones" className={styles.milestonesSection}>
<div className="content-container">
{/* Section Header */}
<div className="section-header">
<div className="section-header" data-reveal="soft">
<span className="section-eyebrow">AWARDS & ACHIEVEMENTS</span>
<div className="section-title-wrap">
<h2 className="section-title">Milestones Along the Way</h2>
@@ -20,11 +20,11 @@ export const MilestonesSection: React.FC = () => {
</div>
{/* Certificate Cards List */}
<div className={styles.cardsList}>
<div className={styles.cardsList} data-reveal-group>
{milestones.map((item) => (
<div key={item.id} className={styles.certCard}>
{/* Thumbnail */}
<div className={styles.thumbnailContainer}>
<div className={styles.thumbnailContainer} data-reveal="image">
<Image
src={item.image}
alt={item.title}
@@ -34,7 +34,7 @@ export const MilestonesSection: React.FC = () => {
</div>
{/* Content Details */}
<div className={styles.certDetails}>
<div className={styles.certDetails} data-reveal="soft">
<div className={styles.titleRow}>
<h3 className={styles.certTitle}>{item.title}</h3>
</div>

View File

@@ -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<HTMLElement>(REVEAL_SELECTOR),
);
if (!elements.length) return;
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
document.querySelectorAll<HTMLElement>('[data-reveal-group]').forEach((group) => {
group.querySelectorAll<HTMLElement>(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;
};

View File

@@ -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);
}
}

View File

@@ -203,7 +203,7 @@ export const ProjectsSection: React.FC = () => {
aria-labelledby="projects-title"
>
<div className="content-container">
<div className={styles.headerRow}>
<div className={styles.headerRow} data-reveal="soft">
<div className="section-header" style={{ marginBottom: 0 }}>
<span className="section-eyebrow">PROJECTS</span>
<div className="section-title-wrap">
@@ -251,10 +251,10 @@ export const ProjectsSection: React.FC = () => {
aria-label="Scrollable project cards"
aria-roledescription="carousel"
>
<div className={styles.carouselTrack}>
<div className={styles.carouselTrack} data-reveal-group>
{projects.map((project) => (
<article key={project.id} className={styles.card} aria-label={project.title}>
<div className={styles.mediaContainer}>
<div className={styles.mediaContainer} data-reveal="image">
<ProjectThumbnail
image={project.image}
title={project.title}
@@ -263,7 +263,7 @@ export const ProjectsSection: React.FC = () => {
/>
</div>
<div className={styles.cardBody}>
<div className={styles.cardBody} data-reveal="soft">
<div className={styles.cardTitleRow}>
<h3 className={styles.cardTitle}>{project.title}</h3>
{project.link && (
@@ -299,7 +299,7 @@ export const ProjectsSection: React.FC = () => {
</div>
</div>
<div className={styles.scrollbarWrapper}>
<div className={styles.scrollbarWrapper} data-reveal="soft">
<div
className={styles.progressTrack}
role="progressbar"

View File

@@ -55,6 +55,22 @@
margin-bottom: var(--space-5);
border-radius: var(--radius-pill);
background: var(--accent-default);
transform-origin: left center;
}
.introBlock[data-visible='true'] .underlineBar {
animation: aboutUnderlineIn 500ms var(--motion-ease-out) 120ms both;
}
@keyframes aboutUnderlineIn {
from {
opacity: 0;
transform: scaleX(0.25);
}
to {
opacity: 1;
transform: scaleX(1);
}
}
.description {
@@ -118,6 +134,33 @@
background: var(--bg-raised);
color: var(--accent-default);
box-shadow: 0 0.375rem 1rem var(--shadow-color);
transition:
color var(--motion-duration-fast) ease,
border-color var(--motion-duration-fast) ease,
transform var(--motion-duration-default) var(--motion-ease-out);
}
@media (hover: hover) and (pointer: fine) {
.stepCard:hover {
border-color: var(--border-strong);
}
.stepCard:hover .stepIcon {
border-color: var(--accent-default);
transform: scale(1.06);
}
}
@media (prefers-reduced-motion: reduce) {
.introBlock[data-visible='true'] .underlineBar {
animation-name: aboutUnderlineFade;
transform: none;
}
@keyframes aboutUnderlineFade {
from { opacity: 0; }
to { opacity: 1; }
}
}
.stepContent {

View File

@@ -24,7 +24,7 @@ export const RiverJourney: React.FC = () => {
<section id="about" className={styles.journeySection} aria-labelledby="about-title">
<AboutRiverScene />
{/* Intro Header & Description on the Left */}
<div className={styles.introBlock}>
<div className={styles.introBlock} data-reveal="scale">
<span className={styles.eyebrow}>ABOUT ME</span>
<h2 id="about-title" className={styles.heading}>From Pixel to Product</h2>
<div className={styles.underlineBar} />
@@ -36,12 +36,16 @@ export const RiverJourney: React.FC = () => {
</p>
</div>
<ol className={styles.stepsList} aria-label="Career journey">
<ol
className={styles.stepsList}
aria-label="Career journey"
data-reveal-group
>
{steps.map((step) => {
const StepIcon = step.icon;
return (
<li key={step.number} className={styles.stepCard}>
<li key={step.number} className={styles.stepCard} data-reveal="soft">
<span className={styles.stepIcon} aria-hidden="true">
<StepIcon size={20} strokeWidth={1.9} />
</span>

View File

@@ -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.

View File

@@ -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; }
}
}

View File

@@ -29,7 +29,7 @@ export const SkillsSection: React.FC = () => {
<section id="skills" className={styles.skillsSection}>
<div className="content-container">
{/* Section Header */}
<div className="section-header">
<div className="section-header" data-reveal="soft">
<span className="section-eyebrow">SKILLS & TOOLS</span>
<div className="section-title-wrap">
<h2 className="section-title">The Stack Behind the Work...</h2>
@@ -38,7 +38,7 @@ export const SkillsSection: React.FC = () => {
</div>
{/* Main Card Container */}
<div className={styles.mainCard} aria-live="polite">
<div className={styles.mainCard} aria-live="polite" data-reveal="scale">
{/* Section: Skills */}
<div className={styles.skillsBlock}>
<span className={styles.cardEyebrow}>SKILLS</span>
@@ -97,7 +97,11 @@ export const SkillsSection: React.FC = () => {
</div>
{/* Filter Tabs Under Card */}
<div className={styles.filterTabsRow} aria-label="Choose skill discipline">
<div
className={styles.filterTabsRow}
aria-label="Choose skill discipline"
data-reveal="soft"
>
{filterTabs.map((tab) => {
const isActive = activeFilter === tab.id;
return (