Retour au catalogue
Features Accordion Visual
Accordion de features a gauche avec preview visuelle a droite qui change selon la feature selectionnee. AnimatePresence pour les transitions fluides entre visuels.
featuresmedium Both Responsive a11y
minimalcorporateelegantsaasagencyuniversalsplit
Theme
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, Zap, Shield, Palette, Code } from "lucide-react";
import type { LucideIcon } from "lucide-react";
interface Feature {
icon: "zap" | "shield" | "palette" | "code";
title: string;
description: string;
visual: string;
}
interface FeaturesAccordionVisualProps {
label?: string;
title?: string;
features?: Feature[];
}
const ICONS: Record<string, LucideIcon> = { zap: Zap, shield: Shield, palette: Palette, code: Code };
const EASE = [0.16, 1, 0.3, 1] as const;
export default function FeaturesAccordionVisual({
label = "Fonctionnalites",
title = "Une solution complete",
features = [
{ icon: "zap", title: "Performance", description: "Infrastructure edge.", visual: "PERF" },
{ icon: "shield", title: "Securite", description: "Chiffrement AES-256.", visual: "SEC" },
{ icon: "palette", title: "Design", description: "Systeme de design.", visual: "DSN" },
{ icon: "code", title: "API", description: "REST & GraphQL.", visual: "API" },
],
}: FeaturesAccordionVisualProps) {
const [activeIdx, setActiveIdx] = useState(0);
const active = features[activeIdx];
const ActiveIcon = ICONS[active?.icon ?? "zap"] ?? Zap;
return (
<section style={{ position: "relative", background: "var(--color-background)", paddingTop: "var(--section-padding-y-lg)", paddingBottom: "var(--section-padding-y-lg)" }}>
<div style={{ width: "100%", maxWidth: "var(--container-max-width)", margin: "0 auto", padding: "0 var(--container-padding-x)" }}>
<div style={{ marginBottom: "3.5rem" }}>
<span style={{ fontFamily: "var(--font-sans)", fontSize: "0.8125rem", fontWeight: 600, color: "var(--color-accent)", textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "0.75rem", display: "block" }}>
{label}
</span>
<h2 style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 800, color: "var(--color-foreground)", letterSpacing: "-0.03em" }}>
{title}
</h2>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "3rem", alignItems: "start" }}>
{/* Left: Accordion */}
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{features.map((feature, i) => {
const Icon = ICONS[feature.icon] ?? Zap;
const isActive = activeIdx === i;
return (
<div key={feature.title}>
<button
onClick={() => setActiveIdx(i)}
style={{ width: "100%", display: "flex", alignItems: "center", gap: "1rem", padding: "1.25rem 1.5rem", borderRadius: "var(--radius-xl)", background: isActive ? "var(--color-accent-subtle)" : "transparent", border: `1px solid ${isActive ? "var(--color-accent)" : "var(--color-border)"}`, cursor: "pointer", textAlign: "left", transition: "all 0.3s ease" }}
>
<div style={{ width: 40, height: 40, borderRadius: "var(--radius-md)", background: isActive ? "var(--color-accent)" : "var(--color-background-alt)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, transition: "background 0.3s" }}>
<Icon style={{ width: 20, height: 20, color: isActive ? "var(--color-background)" : "var(--color-foreground-muted)", transition: "color 0.3s" }} />
</div>
<span style={{ flex: 1, fontFamily: "var(--font-sans)", fontWeight: 600, fontSize: "1rem", color: "var(--color-foreground)" }}>
{feature.title}
</span>
<motion.div animate={{ rotate: isActive ? 180 : 0 }} transition={{ duration: 0.3 }}>
<ChevronDown style={{ width: 18, height: 18, color: "var(--color-foreground-muted)" }} />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease: EASE }}
style={{ overflow: "hidden" }}
>
<p style={{ padding: "0.75rem 1.5rem 1rem", fontSize: "0.9375rem", lineHeight: 1.7, color: "var(--color-foreground-muted)" }}>
{feature.description}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
{/* Right: Visual preview */}
<div style={{ position: "sticky", top: "calc(var(--section-padding-y) + 2rem)", borderRadius: "var(--radius-xl)", background: "var(--color-background-alt)", border: "1px solid var(--color-border)", aspectRatio: "4/3", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center" }}>
<AnimatePresence mode="wait">
<motion.div
key={activeIdx}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={{ duration: 0.4, ease: EASE }}
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "1rem" }}
>
<div style={{ width: 80, height: 80, borderRadius: "var(--radius-xl)", background: "var(--color-accent-subtle)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ActiveIcon style={{ width: 40, height: 40, color: "var(--color-accent)" }} />
</div>
<span style={{ fontFamily: "var(--font-sans)", fontWeight: 700, fontSize: "1.5rem", color: "var(--color-foreground)" }}>
{active?.title}
</span>
<span style={{ fontSize: "0.75rem", fontFamily: "var(--font-sans)", color: "var(--color-accent)", textTransform: "uppercase", letterSpacing: "0.15em", fontWeight: 600 }}>
{active?.visual}
</span>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}