08Timeline cột mốc
Bấm một trong bốn mốc trên trục ngang (thành lập·gọi vốn·mở rộng·hiện tại) thì progress bar đổ đầy tới đúng điểm đó bằng scaleX, đoạn mô tả bên dưới đổi sang nội dung thời điểm ấy.
Dùng choSection lịch sử công ty và câu chuyện tăng trưởng, timeline của press room
scaleXclickaria-current

<main class="stage">
<section class="mt is-demo" data-active="0">
<p class="mt__desc" role="status">Năm 2019, ba nhà sáng lập ra mắt sản phẩm đầu tiên từ một văn phòng nhỏ.</p>
<div class="mt__track">
<span class="mt__fill" aria-hidden="true"></span>
<ol class="mt__list">
<li><button class="mt__stop" type="button" data-i="0" aria-current="step">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2019</span><span class="mt__label">Thành lập</span>
</button></li>
<li><button class="mt__stop" type="button" data-i="1">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2021</span><span class="mt__label">Vòng Series A</span>
</button></li>
<li><button class="mt__stop" type="button" data-i="2">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2023</span><span class="mt__label">Tiến ra quốc tế</span>
</button></li>
<li><button class="mt__stop" type="button" data-i="3">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2026</span><span class="mt__label">1 triệu người dùng</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-paper);
.mt { width: min(420px, 95vw); }
.mt__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-ink;
}
.mt__track { position: relative; }
.mt__track::before {
content: "";
position: absolute; left: 0; right: 0; top: 4px;
height: 3px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .12);
}
.mt__fill {
position: absolute; left: 0; right: 0; top: 4px;
height: 3px;
border-radius: $r-xs;
background: $color;
transform: scaleX(.25);
transform-origin: left; // 규칙8: 진행바 left 채움
transition: transform $duration $easing;
}
.mt__list { position: relative; list-style: none; margin: 0; padding: $sp-3 0 0; display: flex; justify-content: space-between; }
.mt__stop {
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border: 0; padding: 0; background: none; cursor: pointer;
}
.mt__dot {
width: 10px; height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, .3);
transition: background $duration $easing, transform $duration $easing, box-shadow $duration $easing;
}
.mt__stop[aria-current="step"] .mt__dot {
background: $color;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, .18);
}
.mt__year { font-weight: 800; font-size: 10px; line-height: 1; font-family: inherit; color: $subject-ink; }
.mt__label { font-weight: 600; font-size: 9px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); }
// ── 데모 루프: 현재 지점 점이 계속 펄스한다(항상 재생) ──────────────
.mt.is-demo .mt__stop[aria-current="step"] .mt__dot { animation: mt-pulse 2s $easing infinite; }
@keyframes mt-pulse {
0%, 100% { box-shadow: 0 0 0 4px rgba(47, 109, 246, .18); }
50% { box-shadow: 0 0 0 8px rgba(47, 109, 246, .08); }
}
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.mt__desc { min-height: 0; margin-bottom: $sp-2; font-size: 10px; }
.mt__label { display: none; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.mt__fill, .mt__dot { transition: none; }
.mt.is-demo .mt__stop[aria-current="step"] .mt__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mt {
width: min(420px, 95vw);
}
.mt__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: #17141a;
}
.mt__track {
position: relative;
}
.mt__track::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 4px;
height: 3px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.mt__fill {
position: absolute;
left: 0;
right: 0;
top: 4px;
height: 3px;
border-radius: 4px;
background: #2f6df6;
transform: scaleX(0.25);
transform-origin: left;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mt__list {
position: relative;
list-style: none;
margin: 0;
padding: 12px 0 0;
display: flex;
justify-content: space-between;
}
.mt__stop {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
}
.mt__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 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);
}
.mt__stop[aria-current=step] .mt__dot {
background: #2f6df6;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.18);
}
.mt__year {
font-weight: 800;
font-size: 10px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.mt__label {
font-weight: 600;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.mt.is-demo .mt__stop[aria-current=step] .mt__dot {
animation: mt-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes mt-pulse {
0%, 100% {
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.18);
}
50% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.08);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mt__desc {
min-height: 0;
margin-bottom: 8px;
font-size: 10px;
}
.mt__label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.mt__fill, .mt__dot {
transition: none;
}
.mt.is-demo .mt__stop[aria-current=step] .mt__dot {
animation: none !important;
}
}// 실제 동작: 연혁을 클릭하면 그 지점까지 진행바가 scaleX 로 실제로 채워지고 설명 문단이 바뀐다.
var milestoneNotes = [
'Năm 2019, ba nhà sáng lập ra mắt sản phẩm đầu tiên từ một văn phòng nhỏ.',
'Năm 2021, gọi vốn thành công vòng Series A và đội ngũ lớn gấp mười.',
'Năm 2023, mở chi nhánh tại Nhật và Mỹ, bắt đầu tiến ra quốc tế.',
'Năm 2026, vượt mốc một triệu người dùng đã đăng ký.'
];
var root = document.querySelector('.mt');
var stops = root.querySelectorAll('.mt__stop');
var desc = root.querySelector('.mt__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('.mt__fill').style.transform = 'scaleX(' + ((i + 1) / 4) + ')';
stops.forEach(function (s) { s.removeAttribute('aria-current'); });
btn.setAttribute('aria-current', 'step');
desc.textContent = milestoneNotes[i];
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressMilestoneTimeline.module.scss";
export type PressMilestoneTimelineProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STOPS = [
{ year: "2019", label: "창립", desc: "2019년, 세 명이 작은 사무실에서 첫 제품을 출시했습니다." },
{ year: "2021", label: "시리즈 A", desc: "2021년, 시리즈 A 투자를 유치하며 팀을 열 배로 키웠습니다." },
{ year: "2023", label: "해외 진출", desc: "2023년, 일본·미국 지사를 열고 해외 진출을 시작했습니다." },
{ year: "2026", label: "100만 사용자", desc: "2026년, 누적 가입자 100만 명을 넘겼습니다." },
];
export default function PressMilestoneTimeline({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: PressMilestoneTimelineProps) {
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.mt : `${styles.mt} ${styles.demo}`} style={vars}>
<p className={styles.desc} role="status">{STOPS[active].desc}</p>
<div className={styles.track}>
<span className={styles.fill} style={{ transform: `scaleX(${(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.label}>{s.label}</span>
</button>
</li>
))}
</ol>
</div>
</section>
);
}08 Timeline cột mốc — Diện tích đo được 0.438% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 mẫu press page design — dán vào landing
Bài viết chứa demo này
9 mẫu press page design — dán vào landingPress page design là bộ code gom sẵn từng khối của trang báo chí: press release, media kit, liên hệ người phụ trách. Từ Xem tất cả demo trong bài này


