The Experience component displays professional experience and education in a vertical timeline layout with animated reveals triggered by viewport intersection.
Features
Vertical timeline with connecting border line
IntersectionObserver for scroll-triggered animations
Staggered reveals with custom delay per item
Responsive layout with mobile-first design
Hover effects on timeline dots
Internationalized content from translation context
Visual Appearance
The component displays a vertical timeline on the left side (desktop) or as a mobile-friendly card layout. Each experience item includes a glowing dot indicator, title, company/institution name, date range, and description. The timeline dot scales up on hover with a subtle glow effect.
Component Code
import { useEffect , useRef } from 'react' ;
import { useLanguage } from '../context/LanguageContext' ;
export default function Experience () {
const { t } = useLanguage ();
const sectionRef = useRef < HTMLDivElement >( null );
const experiences = [
{ title: t ( 'exp.job1.title' ), company: t ( 'exp.job1.company' ), date: t ( 'exp.job1.date' ), desc: t ( 'exp.job1.desc' ) },
{ title: t ( 'exp.job2.title' ), company: t ( 'exp.job2.company' ), date: t ( 'exp.job2.date' ), desc: t ( 'exp.job2.desc' ) },
{ title: t ( 'exp.edu1.title' ), company: t ( 'exp.edu1.company' ), date: t ( 'exp.edu1.date' ), desc: t ( 'exp.edu1.desc' ) },
];
useEffect (() => {
const io = new IntersectionObserver (( entries ) => {
entries . forEach (( e ) => {
if ( e . isIntersecting ) {
e . target . classList . add ( 'in-view' );
io . unobserve ( e . target );
}
});
}, { threshold: 0.1 });
sectionRef . current ?. querySelectorAll ( '[data-reveal]' ). forEach (( el ) => io . observe ( el ));
return () => io . disconnect ();
}, []);
return (
< section ref = { sectionRef } className = "py-24 md:py-32 px-6 md:px-12 bg-black text-white relative z-10 border-t border-white/10" >
< div className = "max-w-5xl mx-auto" >
< h2 className = "font-wide text-3xl md:text-5xl font-bold uppercase mb-16 md:mb-24 text-center md:text-left" >
{ t ( 'exp.title' ) }
</ h2 >
< div className = "relative border-l border-white/20 ml-4 md:ml-0" >
{ experiences . map (( exp , i ) => (
< div key = { i } data-reveal = "up" style = { { transitionDelay: ` ${ i * 0.1 } s` } } className = "mb-16 md:mb-24 pl-8 md:pl-12 relative group" >
< div className = "absolute w-3 h-3 bg-white rounded-full -left-[6.5px] top-2 group-hover:scale-150 transition-transform duration-300 shadow-[0_0_10px_rgba(255,255,255,0.5)]" ></ div >
< div className = "flex flex-col md:flex-row md:items-baseline justify-between gap-2 md:gap-8 mb-4" >
< h3 className = "font-wide text-xl md:text-2xl font-bold uppercase" > { exp . title } </ h3 >
< span className = "font-sans text-xs md:text-sm tracking-widest uppercase text-white/50 whitespace-nowrap" > { exp . date } </ span >
</ div >
< h4 className = "font-sans text-sm md:text-base tracking-widest uppercase text-white/80 mb-6 font-bold" > { exp . company } </ h4 >
< p className = "font-sans text-sm md:text-base text-white/60 leading-relaxed max-w-3xl" > { exp . desc } </ p >
</ div >
)) }
</ div >
</ div >
</ section >
);
}
Experience Data Structure
Experience items are loaded from the translation context:
const experiences = [
{
title: t ( 'exp.job1.title' ),
company: t ( 'exp.job1.company' ),
date: t ( 'exp.job1.date' ),
desc: t ( 'exp.job1.desc' )
},
{
title: t ( 'exp.job2.title' ),
company: t ( 'exp.job2.company' ),
date: t ( 'exp.job2.date' ),
desc: t ( 'exp.job2.desc' )
},
{
title: t ( 'exp.edu1.title' ),
company: t ( 'exp.edu1.company' ),
date: t ( 'exp.edu1.date' ),
desc: t ( 'exp.edu1.desc' )
},
];
IntersectionObserver Animation
Observer Setup
Element Observation
Staggered Delays
const io = new IntersectionObserver (( entries ) => {
entries . forEach (( e ) => {
if ( e . isIntersecting ) {
e . target . classList . add ( 'in-view' );
io . unobserve ( e . target ); // Unobserve after animation
}
});
}, { threshold: 0.1 }); // Trigger when 10% visible
Timeline Styling
Vertical Line
< div className = "relative border-l border-white/20 ml-4 md:ml-0" >
The timeline uses a left border on the container element.
Timeline Dots
< div className = "absolute w-3 h-3 bg-white rounded-full -left-[6.5px] top-2 group-hover:scale-150 transition-transform duration-300 shadow-[0_0_10px_rgba(255,255,255,0.5)]" ></ div >
Each experience has a white dot positioned at -left-[6.5px] to center it on the border line. On hover, the dot scales to 150% with a glow shadow effect.
Layout Structure
Each experience item follows this structure:
Timeline Dot (absolute positioning)
Header Row (title + date)
Desktop: Flexbox row with space-between
Mobile: Stacked column
Company/Institution Name
Description
Responsive Behavior
Timeline line visible with 4px left margin
Stacked layout (title above date)
8px left padding
Smaller text sizes
Timeline line flush to content
Horizontal layout (title and date in row)
12px left padding
Larger text sizes
Animation Behavior
Elements with data-reveal="up" are observed by IntersectionObserver:
Initially hidden/offset (CSS not shown in component)
When 10% visible, in-view class is added
Each item has a staggered delay: 0s, 0.1s, 0.2s
After animation, observer disconnects to improve performance
CSS Requirements
The component relies on external CSS for reveal animations:
/* Expected CSS (not in component) */
[ data-reveal = "up" ] {
opacity : 0 ;
transform : translateY ( 20 px );
transition : opacity 0.6 s , transform 0.6 s ;
}
[ data-reveal = "up" ] .in-view {
opacity : 1 ;
transform : translateY ( 0 );
}
Dependencies
Translation context providing the t() function for internationalized content
Native browser API for efficient scroll-based animations
Accessibility
Semantic HTML with proper heading hierarchy
Adequate color contrast (white text on black background)
Responsive text sizing
Clear visual hierarchy
The IntersectionObserver automatically:
Unobserves elements after animation
Disconnects on component unmount
Uses efficient threshold-based detection (10% visibility)
Source Location
~/workspace/source/src/components/Experience.tsx