feat(motion): add scroll reveals and micro-interactions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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's build something meaningful.
|
||||
</h2>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
75
src/components/MotionController.tsx
Normal file
75
src/components/MotionController.tsx
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user