05Timeline kinh nghiệm
Bấm một trong bốn mốc trên trục đứng thì progress line đổ đầy tới đúng điểm đó bằng scaleY, đoạn mô tả phía trên đổi sang nội dung thời điểm ấy.
Dùng choTimeline kinh nghiệm·học vấn trên trang CV·giới thiệu
scaleYclickaria-current

<main class="stage">
<section class="et is-demo" data-active="0">
<p class="et__desc" role="status">Năm 2023, tôi dẫn dắt Dự án A từ đầu đến cuối.</p>
<div class="et__track">
<span class="et__fill" aria-hidden="true"></span>
<ol class="et__list">
<li><button class="et__stop" type="button" data-i="0" aria-current="step">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2023</span><span class="et__role">Trưởng Dự án A</span>
</button></li>
<li><button class="et__stop" type="button" data-i="1">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2022</span><span class="et__role">Tham gia Dự án B</span>
</button></li>
<li><button class="et__stop" type="button" data-i="2">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2021</span><span class="et__role">Thực tập Dự án C</span>
</button></li>
<li><button class="et__stop" type="button" data-i="3">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2020</span><span class="et__role">Tốt nghiệp chuyên ngành</span>
</button></li>
</ol>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 진행선·설명 전환
$easing: $ease-spring;
$color: $subject-blue; // 진행선·현재 지점 색
@include stage($stage-ink);
.et { width: min(360px, 94vw); }
.et__desc {
min-height: 32px;
margin: 0 0 $sp-3;
font-weight: 600; font-size: clamp(11px, 3.2vw, 12px); line-height: 1.5; font-family: inherit;
color: $subject-cream;
}
.et__track { position: relative; padding-left: $sp-5; }
.et__track::before {
content: "";
position: absolute; left: 4px; top: 4px; bottom: 4px;
width: 3px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .16);
}
.et__fill {
position: absolute; left: 4px; top: 4px; bottom: 4px;
width: 3px;
border-radius: $r-xs;
background: $color;
transform: scaleY(.25);
transform-origin: top; // 규칙8: 진행선 위에서 아래로
transition: transform $duration $easing;
}
.et__list { position: relative; list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-4; }
.et__stop {
position: relative;
display: flex; align-items: center; gap: $sp-2;
border: 0; padding: 0; background: none; cursor: pointer;
text-align: left;
}
.et__dot {
flex: 0 0 auto;
width: 10px; height: 10px;
margin-left: -16px;
border-radius: 50%;
background: rgba(255, 247, 230, .3);
transition: background $duration $easing, transform $duration $easing, box-shadow $duration $easing;
}
.et__stop[aria-current="step"] .et__dot {
background: $color;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, .22);
}
.et__year { font-weight: 800; font-size: 10px; line-height: 1; font-family: inherit; color: $subject-cream; min-width: 30px; }
.et__role { font-weight: 600; font-size: 11px; line-height: 1.3; font-family: inherit; color: rgba(255, 247, 230, .68); }
// ── 데모 루프: 현재 지점 점이 계속 펄스한다(항상 재생) ──────────────
.et.is-demo .et__stop[aria-current="step"] .et__dot { animation: et-pulse 2s $easing infinite; }
@keyframes et-pulse {
0%, 100% { box-shadow: 0 0 0 4px rgba(47, 109, 246, .22); }
50% { box-shadow: 0 0 0 8px rgba(47, 109, 246, .1); }
}
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.et__desc { min-height: 0; margin-bottom: $sp-2; font-size: 10px; }
.et__list { gap: $sp-2; }
.et__role { display: none; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.et__fill, .et__dot { transition: none; }
.et.is-demo .et__stop[aria-current="step"] .et__dot { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.et {
width: min(360px, 94vw);
}
.et__desc {
min-height: 32px;
margin: 0 0 12px;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1.5;
font-family: inherit;
color: #fff7e6;
}
.et__track {
position: relative;
padding-left: 20px;
}
.et__track::before {
content: "";
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.16);
}
.et__fill {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 4px;
background: #2f6df6;
transform: scaleY(0.25);
transform-origin: top;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.et__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.et__stop {
position: relative;
display: flex;
align-items: center;
gap: 8px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
text-align: left;
}
.et__dot {
flex: 0 0 auto;
width: 10px;
height: 10px;
margin-left: -16px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.3);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.et__stop[aria-current=step] .et__dot {
background: #2f6df6;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.22);
}
.et__year {
font-weight: 800;
font-size: 10px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
min-width: 30px;
}
.et__role {
font-weight: 600;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.68);
}
.et.is-demo .et__stop[aria-current=step] .et__dot {
animation: et-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes et-pulse {
0%, 100% {
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.22);
}
50% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.et__desc {
min-height: 0;
margin-bottom: 8px;
font-size: 10px;
}
.et__list {
gap: 8px;
}
.et__role {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.et__fill, .et__dot {
transition: none;
}
.et.is-demo .et__stop[aria-current=step] .et__dot {
animation: none !important;
}
}// 실제 동작: 경력 항목을 클릭하면 그 지점까지 세로 진행선이 scaleY 로 실제로 채워지고 설명 문단이 바뀐다.
var careerNotes = [
'Năm 2023, tôi dẫn dắt Dự án A từ đầu đến cuối.',
'Năm 2022, tôi tham gia Dự án B với vai trò phụ trách front-end.',
'Năm 2021, tôi dựng UI ở vị trí thực tập sinh trong nhóm Dự án C.',
'Năm 2020, tôi hoàn thành chuyên ngành và khởi động side project đầu tiên.'
];
var root = document.querySelector('.et');
var stops = root.querySelectorAll('.et__stop');
var desc = root.querySelector('.et__desc');
stops.forEach(function (btn) {
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var i = Number(btn.getAttribute('data-i'));
root.setAttribute('data-active', String(i));
root.querySelector('.et__fill').style.transform = 'scaleY(' + ((i + 1) / stops.length) + ')';
stops.forEach(function (s) { s.removeAttribute('aria-current'); });
btn.setAttribute('aria-current', 'step');
desc.textContent = careerNotes[i];
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectExperienceTimeline.module.scss";
export type ProjectExperienceTimelineProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STOPS = [
{ year: "2023", role: "프로젝트 A 리드", desc: "2023년, 프로젝트 A 를 처음부터 끝까지 리드했습니다." },
{ year: "2022", role: "프로젝트 B 참여", desc: "2022년, 프로젝트 B 에 프런트엔드 담당으로 참여했습니다." },
{ year: "2021", role: "프로젝트 C 인턴", desc: "2021년, 프로젝트 C 팀에서 인턴으로 UI 를 구현했습니다." },
{ year: "2020", role: "전공 수료", desc: "2020년, 전공을 마치고 첫 사이드 프로젝트를 시작했습니다." },
];
export default function ProjectExperienceTimeline({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: ProjectExperienceTimelineProps) {
const [active, setActive] = useState(0);
const [interacted, setInteracted] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={interacted ? styles.et : `${styles.et} ${styles.demo}`} style={vars}>
<p className={styles.desc} role="status">{STOPS[active].desc}</p>
<div className={styles.track}>
<span className={styles.fill} style={{ transform: `scaleY(${(active + 1) / STOPS.length})` }} aria-hidden="true" />
<ol className={styles.list}>
{STOPS.map((s, i) => (
<li key={s.year}>
<button
type="button"
className={styles.stop}
aria-current={i === active ? "step" : undefined}
onClick={() => { setInteracted(true); setActive(i); }}
>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.year}>{s.year}</span>
<span className={styles.role}>{s.role}</span>
</button>
</li>
))}
</ol>
</div>
</section>
);
}05 Timeline kinh nghiệm — Diện tích đo được 0.51% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 mẫu portfolio grid css — dán vào landing
Bài viết chứa demo này
9 mẫu portfolio grid css — dán vào landingPortfolio grid css là bộ code gom sẵn từng khối của trang portfolio cá nhân: lưới dự án, case study, kinh nghiệm, form l Xem tất cả demo trong bài này


