A scroll-friendly timeline component for product roadmaps, company histories, launch stories, case-study progressions, and process pages where time needs to feel designed rather than dumped into a list.

Timeline gives chronological content a clean visual spine.
Milestones sit along a horizontal or structured path, with each entry anchored to a point in the sequence. Dates, titles, and short descriptions appear as the user moves through the section, creating a guided sense of progress without forcing the page into a heavy storytelling format.
Use Timeline when the page needs to explain how something evolved: a product roadmap, company journey, campaign rollout, case-study process, funding history, research phase, implementation sequence, or strategic transformation. The component works best when each milestone has a clear date, one strong label, and one concise explanation.
The page job is sequence. The visitor should understand what happened, when it happened, and why it mattered. The visual line should clarify the story, not decorate a weak list of dates.
npx hyperiux add timelineimport Timeline from "@/components/effects/timeline";
export default function page() {
return (
<>
<Timeline />
</>
);
}// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import Image from "next/image";
import { useRef, useSyncExternalStore, } from "react";
import gsap from "gsap";
import ScrollTrigger from "gsap/dist/ScrollTrigger";
import SplitText from "gsap/dist/SplitText";
import { useGSAP } from "@gsap/react";
import { ReactLenis } from "lenis/react";
gsap.registerPlugin(ScrollTrigger, SplitText);
const monthOrder = {
January: 1,
February: 2,
March: 3,
April: 4,
May: 5,
June: 6,
July: 7,
August: 8,
September: 9,
October: 10,
November: 11,
December: 12,
};
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeToReducedMotion(callback) {
if (typeof window === "undefined")
return () => { };
const mediaQueryList = window.matchMedia(REDUCED_MOTION_QUERY);
mediaQueryList.addEventListener("change", callback);
return () => mediaQueryList.removeEventListener("change", callback);
}
function getReducedMotionSnapshot() {
if (typeof window === "undefined")
return false;
return window.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false;
}
function getServerReducedMotionSnapshot() {
return false;
}
function usePrefersReducedMotion() {
return useSyncExternalStore(subscribeToReducedMotion, getReducedMotionSnapshot, getServerReducedMotionSnapshot);
}
const topJourneyData = [
{
id: "2020-march",
year: "2020",
month: "March",
content: "Signal research turns scattered notes into a clear product thesis",
},
{
id: "2021-july",
year: "2021",
month: "July",
content: "Founding release ships with the first live customer journeys",
},
{
id: "2023-april",
year: "2023",
month: "April",
content: "Automation layer connects insight, publishing, and sales motion",
},
{
id: "2026-may",
year: "2026",
month: "May",
content: "New markets open with localized launches and faster onboarding",
},
];
const bottomJourneyData = [
{
id: "2020-november",
year: "2020",
month: "November",
content: "Prototype sprint validates the experience with real operators",
},
{
id: "2022-october",
year: "2022",
month: "October",
content: "Community feedback reshapes the roadmap into sharper releases",
},
{
id: "2025-september",
year: "2025",
month: "September",
content: "Companion mobile workflows make the timeline travel-ready",
},
];
const allJourneyItems = [
...topJourneyData,
...bottomJourneyData,
].sort((a, b) => {
const yearDiff = Number(a.year) - Number(b.year);
if (yearDiff !== 0)
return yearDiff;
return monthOrder[a.month] - monthOrder[b.month];
});
export default function Timeline({ title = "Product Storyline", periodLabel = "2020-2026", textColor = "#000000", mutedTextColor = "#3f3f46", activeColor = "#ff5f00", backgroundColor = "#ffffff", imageUrl = "https://pub-8abee449136941f5b0a1cd2c014534e9.r2.dev/vault-listing-images/assets-images/h-15.jpg", imageAlt = "Modern office workspace", duration, scrollDuration = 1.2, }) {
const sectionRef = useRef(null);
const wholeSliderRef = useRef(null);
const reducedMotion = usePrefersReducedMotion();
const animationDuration = duration ?? scrollDuration;
const normalizedDuration = Math.max(0.2, animationDuration);
const sectionStyle = {
color: textColor,
backgroundColor,
};
const activeStyle = {
backgroundColor: activeColor,
};
const mutedTextStyle = {
color: mutedTextColor,
};
useGSAP(() => {
const section = sectionRef.current;
if (!section)
return;
const isTablet = window.innerWidth >= 642 && window.innerWidth <= 1024;
const isMobile = window.innerWidth < 642;
const slidePercent = isTablet ? -60 : isMobile ? -57 : -65;
const lineWidth = isTablet ? "75%" : isMobile ? "65%" : "98%";
const lineStart = isTablet ? "top 20%" : isMobile ? "top 30%" : "top 25%";
const slideEnd = isMobile ? "82% 50%" : "92% bottom";
const lineEnd = isMobile ? "80% 50%" : isTablet ? "90% bottom" : "92% bottom";
const tl = gsap.timeline({
scrollTrigger: {
trigger: section,
start: "2% top",
end: slideEnd,
scrub: true,
},
defaults: {
ease: "none",
},
});
tl.fromTo(wholeSliderRef.current, { xPercent: 0 }, { xPercent: slidePercent });
if (reducedMotion) {
gsap.set(".journey-line", { width: lineWidth });
return;
}
gsap.to(".journey-line", {
width: lineWidth,
ease: "none",
scrollTrigger: {
trigger: section,
start: lineStart,
end: lineEnd,
scrub: true,
},
});
}, { dependencies: [reducedMotion], scope: sectionRef });
useGSAP(() => {
const section = sectionRef.current;
if (!section)
return;
const items = allJourneyItems;
if (reducedMotion) {
items.forEach((item) => {
gsap.set(`.jl-${item.id}`, { scaleY: 1 });
gsap.set(`.jd-${item.id}`, { scale: 1 });
gsap.set(`.title-${item.id}`, { opacity: 1, clearProps: "transform" });
gsap.set(`.description-${item.id}`, {
opacity: 1,
clearProps: "transform",
});
});
return;
}
items.forEach((item) => {
gsap.set(`.jl-${item.id}`, {
scaleY: 0,
transformOrigin: "bottom bottom",
});
gsap.set(`.jd-${item.id}`, { scale: 0 });
gsap.set(`.title-${item.id}`, { opacity: 1 });
gsap.set(`.description-${item.id}`, { opacity: 1 });
});
const titleSplits = {};
const descriptionSplits = {};
items.forEach((item) => {
titleSplits[item.id] = new SplitText(`.title-${item.id}`, {
type: "chars, words, lines",
mask: "lines",
});
descriptionSplits[item.id] = new SplitText(`.description-${item.id}`, {
type: "chars, words, lines",
mask: "lines",
});
});
const createItemTimeline = (item, startPos, endPos) => {
const lineSelector = `.jl-${item.id}`;
const dotSelector = `.jd-${item.id}`;
const titleLines = titleSplits[item.id]?.lines || [];
const descriptionLines = descriptionSplits[item.id]?.lines || [];
const isTop = topJourneyData.some((topItem) => topItem.id === item.id);
if (!isTop) {
gsap.set(lineSelector, { transformOrigin: "top top" });
}
const timeline = gsap.timeline({
scrollTrigger: {
trigger: section,
start: `${startPos}% 38%`,
end: `${endPos}% 50%`,
scrub: true,
},
});
timeline
.to(lineSelector, {
scaleY: 1,
duration: normalizedDuration * 0.4,
})
.to(dotSelector, {
scale: 1,
duration: normalizedDuration * 0.4,
}, "<")
.fromTo(titleLines, { y: 100 }, {
y: 0,
delay: -0.8 * normalizedDuration,
duration: normalizedDuration,
stagger: 0.02,
ease: "power2.out",
})
.fromTo(descriptionLines, { y: 100 }, {
y: 0,
duration: normalizedDuration,
stagger: 0.02,
ease: "power2.out",
}, "<");
return timeline;
};
const positions = window.innerWidth < 642
? [
[22, 32],
[28, 38],
[36, 46],
[45, 55],
[52, 62],
[60, 70],
[69, 79],
]
: window.innerWidth >= 642 && window.innerWidth <= 1024
? [
[16, 27],
[24, 36],
[35, 47],
[45, 57],
[55, 67],
[62, 74],
[70, 82],
]
: [
[6, 26],
[16, 36],
[26, 46],
[35, 55],
[45, 65],
[55, 75],
[65, 85],
];
items.forEach((item, index) => {
const [startPos, endPos] = positions[index];
createItemTimeline(item, startPos, endPos);
});
const handleResize = () => {
ScrollTrigger.refresh();
};
window.addEventListener("resize", handleResize);
return () => {
Object.values(titleSplits).forEach((split) => split?.revert?.());
Object.values(descriptionSplits).forEach((split) => split?.revert?.());
window.removeEventListener("resize", handleResize);
};
}, { dependencies: [normalizedDuration, reducedMotion], scope: sectionRef });
const content = (<section ref={sectionRef} id="journey" className="h-[200vw] max-[1025px]:h-[400vh] max-md:h-[400vh] w-full relative py-[7%]" style={sectionStyle}>
<div className="h-screen w-screen sticky top-[10%] pt-[5%] overflow-hidden max-md:top-[5%] ">
<div ref={wholeSliderRef} className="mr-[2vw] flex h-[30vw] w-[240vw] items-center gap-[5vw] px-[5vw] max-[1025px]:h-[70vh] max-[1025px]:w-[400vw] max-[1025px]:flex-col max-[1025px]:items-start max-[1025px]:gap-[2vw] max-[1025px]:px-[5vw] max-md:h-[80vh] max-md:w-[800vw] max-md:px-[7vw]">
<div className="h-full w-[30vw] overflow-hidden rounded-[1vw] max-[1025px]:h-[40vw] max-[1025px]:w-[15%] max-[1025px]:rounded-[3vw] max-md:h-[65vw] max-md:w-[85vw] max-md:rounded-[5vw]">
<Image src={imageUrl} alt={imageAlt} width={500} height={500} className="h-full w-full object-cover"/>
</div>
<div className="relative h-full w-full max-[1025px]:h-[50%]">
<div className="w-full absolute left-0 top-[49%] tranlate-y-[-50%] flex items-center h-fit">
<div className="h-[.8vw] max-md:h-[2vw] max-md:w-[2vw] w-[.8vw] rounded-full max-[1025px]:w-[1.5vw] max-[1025px]:h-[1.5vw]" style={activeStyle}></div>
<div className="h-px w-[0%] rounded-full journey-line" style={activeStyle}></div>
<div className="h-[.8vw] max-md:h-[2vw] max-md:w-[2vw] w-[.8vw] rounded-full max-[1025px]:w-[1.5vw] max-[1025px]:h-[1.5vw]" style={activeStyle}></div>
</div>
<div className="flex h-1/2 w-full items-center justify-start gap-[.5vw]">
<div className="h-full w-[20%] pt-[2vw] max-md:h-fit max-md:pt-[5vw]">
<h2 className="w-[65%] text-[3vw] leading-[0.95] max-[1025px]:w-[75%] max-[1025px]:text-[7vw] max-md:text-[8.5vw]">
{title}
</h2>
</div>
<div className="w-full flex h-full gap-x-[15vw] max-md:gap-x-[40vw]">
{topJourneyData.map((item) => (<div key={`top-${item.id}`} className="relative h-full w-[30vw] px-[3vw] max-[1025px]:w-[50vw] max-[1025px]:px-[5vw] max-md:flex max-md:w-[70vw] max-md:flex-col max-md:px-[7vw]">
<div className="w-full absolute left-0 bottom-0 top-0 h-full">
<div className={`size-[1vw] max-md:size-[2.5vw] max-[1025px]:size-[2vw] translate-x-[-50%] relative aspect-square rounded-full jd-${item.id}`} style={activeStyle}></div>
<div className={`h-[94%] w-px origin-bottom rounded-full jl-${item.id}`} style={activeStyle}></div>
</div>
<div className="mt-[-1vw] space-y-[1vw] max-[1025px]:mt-[-1.5vw] max-md:mt-[-2vw]">
<h4 className={`title-${item.id} text-[2.5vw] leading-none max-[1025px]:text-[5vw] max-md:text-[6.4vw]`}>
{item.year} {item.month}
</h4>
<p className={`description-${item.id} w-[90%] text-[1.5vw] leading-[1.15] max-[1025px]:w-[70%] max-[1025px]:text-[3.2vw] max-md:w-[90%] max-md:text-[4.8vw]`} style={mutedTextStyle}>
{item.content}
</p>
</div>
</div>))}
</div>
</div>
<div className="h-1/2 flex items-center justify-start w-full">
<div className="w-[34%] pt-[2vw] max-md:pt-[5vw] max-md:w-[30%] h-full max-[1025px]:pt-[5vw]">
<p className=" text-[1.65vw] leading-none max-[1025px]:text-[3vw] max-md:text-[4.2vw]" style={mutedTextStyle}>
{periodLabel}
</p>
</div>
<div className="w-full flex h-full gap-x-[20vw] ml-[7vw] max-md:gap-x-[40vw] max-[1025px]:ml-0 max-md:ml-[7vw]">
{bottomJourneyData.map((item) => (<div key={`bottom-${item.id}`} className="relative h-full w-[25vw] px-[3vw] max-[1025px]:flex max-[1025px]:w-[20%] max-[1025px]:flex-col max-[1025px]:justify-center max-[1025px]:px-[5vw] max-md:w-[70vw] max-md:px-[7vw]">
<div className="w-full absolute left-0 bottom-[-1%] h-full">
<div className={`h-[94%] origin-top w-px rounded-full max-md:h-full jl-${item.id}`} style={activeStyle}></div>
<div className={`size-[1vw] max-md:size-[2.5vw] max-[1025px]:size-[2vw] translate-x-[-50%] relative w-auto aspect-square rounded-full jd-${item.id}`} style={activeStyle}></div>
</div>
<div className="flex h-full w-full flex-col justify-end space-y-[1vw]">
<h4 className={`title-${item.id} text-[2.5vw] leading-none max-[1025px]:text-[5vw] max-md:text-[6.4vw]`}>
{item.year} {item.month}
</h4>
<p className={`description-${item.id} w-[90%] text-[1.5vw] leading-[1.15] max-[1025px]:w-[70%] max-[1025px]:text-[3.2vw] max-md:w-[90%] max-md:text-[4.8vw]`} style={mutedTextStyle}>
{item.content}
</p>
</div>
</div>))}
</div>
</div>
</div>
</div>
</div>
</section>);
return (<ReactLenis root options={{
duration: reducedMotion
? Math.min(normalizedDuration, 0.6)
: normalizedDuration,
smoothWheel: true,
syncTouch: true,
touchMultiplier: 1,
wheelMultiplier: 1,
}}>
{content}
</ReactLenis>);
}
A SaaS company can use Timeline on a product story page to show how the platform moved from research, to prototype, to public launch, to automation, to new-market expansion. Each milestone helps the visitor understand momentum without reading a dense company-history paragraph.
The outcome is credibility. The company feels like it has a real trajectory, not just a homepage claim that says “built for the future” and hopes no one asks follow-up questions.
An agency can use the same component for case-study process pages: discovery, audit, UX direction, interface design, frontend build, launch, and iteration. The visual sequence helps clients understand the work without turning the page into a project-management export.
• Product roadmap and product-history pages.
• Company journey, funding, launch, or growth-story sections.
• Case studies that need to explain process over time.
• Agency methodology and implementation sequences.
• Campaign rollouts where each phase needs a clear place in the story.
• Onboarding or adoption flows where users need chronological orientation.
Not for dense release notes, legal timelines, documentation tables, long biographies, pricing explanations, or content that needs direct comparison.
Not for weak chronology. If the dates do not tell a meaningful story, a timeline will only make the emptiness more organized.
Not for pages where users need all information immediately visible. Use a table or structured list when scanning matters more than narrative.
Keep the milestone count controlled, reserve layout space, animate connector lines and markers with restraint, and avoid measuring every item on every scroll event. If scroll drives progress, throttle updates and clean up observers or timelines on unmount.
Large images inside timeline items should be optimized and lazy-loaded where appropriate. The timeline should feel smooth, not like each milestone is asking the browser to reconsider its life choices.
Preserve a logical reading order. Screen-reader users should receive the milestones in chronological sequence, with dates and descriptions grouped correctly. Decorative connector lines and markers should not be announced as content.
On mobile, switch to a vertical or stacked layout when horizontal space becomes cramped. Keep touch targets clear, avoid tiny markers, and make dates easy to read. For reduced motion, show the timeline entries without scroll-linked line drawing, marker travel, or staggered reveal.
• Adding too many milestones.
• Writing milestone descriptions that are too long.
• Making the timeline line more important than the story.
• Breaking chronological order visually.
• Hiding meaningful dates inside images or decorative layers.
• Using horizontal layouts on mobile without a simpler fallback.
• Making scroll progress feel like fake loading.
| Prop | Type | Default | Description |
|---|---|---|---|
textColor | color | #000000 | Text color used for the timeline title, dates, and milestone descriptions. |
mutedTextColor | color | #3f3f46 | Secondary text color for descriptions and the period label. |
activeColor | color | #ff5f00 | Accent color shared by the active timeline line, dots, and milestone stems. |
backgroundColor | color | #ffffff | Background color of the timeline section. |
duration | number | 1.2 | Reveal animation and Lenis smoothing duration in seconds. |
Timeline is a React component for presenting dated milestones, product history, process steps, or chronological stories along a structured visual path.
Use it when time, sequence, or progression matters: product roadmaps, company histories, case studies, campaign rollouts, launch stories, and implementation processes.
Use Timeline when chronology adds meaning. Use a normal list when the items are equal, unrelated, or mainly need quick scanning.
Five to nine milestones usually work well for a page section. More than that may need filtering, grouping, pagination, or a dedicated history page.
Yes, but keep images purposeful. Use them when they clarify the milestone, not just because the layout has space.
Use real text for dates and descriptions, preserve chronological reading order, hide decorative connector lines from assistive technology, and keep keyboard navigation predictable if items are interactive.
Reduced motion should show the timeline in its final readable state or use minimal fades. Remove scroll-linked line drawing, marker travel, and repeated staggered motion.
Yes. A custom version should define milestone structure, orientation, marker style, scroll behavior, mobile layout, accessibility treatment, reduced-motion fallback, content rules, source handoff, and implementation notes.
Need a custom effect? Tell us what to create.