Retour au catalogue
Feature Showcase Timeline Scroll
Timeline verticale de features revelee au scroll avec ligne de progression animee.
feature-showcasemedium Both Responsive a11y
minimalcorporatesaasagencystacked
Theme
"use client";
import { useRef } from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import {
UserPlus,
Settings,
Play,
TrendingUp,
Rocket,
type LucideIcon,
} from "lucide-react";
const iconMap: Record<string, LucideIcon> = {
UserPlus,
Settings,
Play,
TrendingUp,
Rocket,
};
interface TimelineFeature {
icon: string;
title: string;
description: string;
imageUrl?: string;
}
interface FeatureShowcaseTimelineScrollProps {
title?: string;
subtitle?: string;
features?: TimelineFeature[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
function TimelineItem({
feature,
index,
isRight,
}: {
feature: TimelineFeature;
index: number;
isRight: boolean;
}) {
const Icon = iconMap[feature.icon] || Rocket;
return (
<motion.div
initial={{ opacity: 0, x: isRight ? 40 : -40 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.6, delay: 0.1, ease: EASE }}
style={{
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
gap: "2rem",
alignItems: "center",
minHeight: 200,
}}
>
{/* Left content */}
<div
style={{
textAlign: isRight ? "right" : "left",
order: isRight ? 0 : 2,
}}
>
{!isRight && (
<div>
<h3
style={{
fontSize: "1.125rem",
fontWeight: 700,
color: "var(--color-foreground)",
marginBottom: "0.5rem",
}}
>
{feature.title}
</h3>
<p
style={{
fontSize: "0.875rem",
color: "var(--color-foreground-muted)",
lineHeight: 1.7,
}}
>
{feature.description}
</p>
</div>
)}
{isRight && feature.imageUrl && (
<img
src={feature.imageUrl}
alt={feature.title}
style={{
width: "100%",
maxWidth: 360,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
marginLeft: "auto",
}}
/>
)}
</div>
{/* Center node */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "relative",
}}
>
<motion.div
whileInView={{ scale: [0, 1.15, 1] }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.2 + index * 0.05 }}
style={{
width: 48,
height: 48,
borderRadius: "50%",
background: "var(--color-accent)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 0 20px color-mix(in srgb, var(--color-accent) 30%, transparent)",
zIndex: 2,
}}
>
<Icon style={{ width: 22, height: 22, color: "var(--color-background)" }} />
</motion.div>
<span
style={{
fontSize: "0.6875rem",
fontWeight: 700,
color: "var(--color-accent)",
marginTop: "0.5rem",
}}
>
{String(index + 1).padStart(2, "0")}
</span>
</div>
{/* Right content */}
<div
style={{
textAlign: isRight ? "left" : "right",
order: isRight ? 2 : 0,
}}
>
{isRight && (
<div>
<h3
style={{
fontSize: "1.125rem",
fontWeight: 700,
color: "var(--color-foreground)",
marginBottom: "0.5rem",
}}
>
{feature.title}
</h3>
<p
style={{
fontSize: "0.875rem",
color: "var(--color-foreground-muted)",
lineHeight: 1.7,
}}
>
{feature.description}
</p>
</div>
)}
{!isRight && feature.imageUrl && (
<img
src={feature.imageUrl}
alt={feature.title}
style={{
width: "100%",
maxWidth: 360,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
}}
/>
)}
</div>
</motion.div>
);
}
export default function FeatureShowcaseTimelineScroll({
title = "Comment ca marche",
subtitle = "Parcours produit",
features = [],
}: FeatureShowcaseTimelineScrollProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start center", "end center"],
});
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
return (
<section
style={{
paddingTop: "var(--section-padding-y)",
paddingBottom: "var(--section-padding-y)",
background: "var(--color-background)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
}}
>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: EASE }}
style={{ textAlign: "center", marginBottom: "3.5rem" }}
>
<p
style={{
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.1em",
color: "var(--color-accent)",
marginBottom: "0.5rem",
}}
>
{subtitle}
</p>
<h2
style={{
fontSize: "clamp(1.75rem, 3vw, 2.5rem)",
fontWeight: 700,
color: "var(--color-foreground)",
}}
>
{title}
</h2>
</motion.div>
<div ref={containerRef} style={{ position: "relative" }}>
{/* Background line */}
<div
style={{
position: "absolute",
left: "50%",
top: 0,
bottom: 0,
width: 2,
transform: "translateX(-50%)",
background: "var(--color-border)",
}}
/>
{/* Animated progress line */}
<motion.div
style={{
position: "absolute",
left: "50%",
top: 0,
width: 2,
transform: "translateX(-50%)",
background: "var(--color-accent)",
height: lineHeight,
zIndex: 1,
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: "3rem" }}>
{features.map((feature, i) => (
<TimelineItem
key={i}
feature={feature}
index={i}
isRight={i % 2 === 0}
/>
))}
</div>
</div>
</div>
</section>
);
}