Retour au catalogue
Features Animated Reveal
Features qui se revelent une par une au scroll avec stagger alterne gauche/droite.
featuresmedium Both Responsive a11y
elegantcorporateuniversalsaasagencystacked
Theme
"use client";
import React from "react";
import { motion } from "framer-motion";
import * as LucideIcons from "lucide-react";
interface FeatureItem {
id: string;
title: string;
description: string;
icon?: string;
}
interface FeaturesAnimatedRevealProps {
badge?: string;
title?: string;
subtitle?: string;
features: FeatureItem[];
}
function getIcon(name?: string) {
if (!name) return null;
return (LucideIcons as unknown as Record<string, React.ElementType>)[name] || null;
}
const EASE = [0.16, 1, 0.3, 1] as const;
export default function FeaturesAnimatedReveal({
badge,
title,
subtitle,
features,
}: FeaturesAnimatedRevealProps) {
return (
<section
style={{
padding: "var(--section-padding-y, 6rem) 0",
background: "var(--color-background)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
}}
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, ease: EASE }}
style={{ textAlign: "center", maxWidth: 600, margin: "0 auto 4rem" }}
>
{badge && (
<span
style={{
display: "inline-block",
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--color-accent)",
marginBottom: "1rem",
}}
>
{badge}
</span>
)}
{title && (
<h2
style={{
fontFamily: "var(--font-sans)",
fontSize: "clamp(1.75rem, 3vw, 2.75rem)",
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.15,
color: "var(--color-foreground)",
marginBottom: "1rem",
}}
>
{title}
</h2>
)}
{subtitle && (
<p
style={{
fontSize: "1.0625rem",
lineHeight: 1.7,
color: "var(--color-foreground-muted)",
}}
>
{subtitle}
</p>
)}
</motion.div>
{/* Staggered reveal list */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
maxWidth: 720,
margin: "0 auto",
}}
>
{features.map((feature, i) => {
const Icon = getIcon(feature.icon);
return (
<motion.div
key={feature.id}
initial={{ opacity: 0, x: i % 2 === 0 ? -30 : 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.1, duration: 0.6, ease: EASE }}
style={{
display: "flex",
alignItems: "flex-start",
gap: "1.25rem",
padding: "1.5rem",
borderRadius: "var(--radius-lg, 1rem)",
border: "1px solid var(--color-border)",
background: "var(--color-background-card, var(--color-background))",
transition: "border-color var(--duration-normal) var(--ease-out)",
}}
>
{Icon && (
<div
style={{
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: "var(--radius-md, 0.75rem)",
background:
"color-mix(in srgb, var(--color-accent) 10%, transparent)",
}}
>
<Icon
style={{
width: 20,
height: 20,
color: "var(--color-accent)",
}}
/>
</div>
)}
<div>
<h3
style={{
fontFamily: "var(--font-sans)",
fontSize: "1rem",
fontWeight: 600,
color: "var(--color-foreground)",
marginBottom: "0.375rem",
}}
>
{feature.title}
</h3>
<p
style={{
fontSize: "0.9375rem",
lineHeight: 1.6,
color: "var(--color-foreground-muted)",
margin: 0,
}}
>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
);
}