Retour au catalogue
Feature Showcase Orbit
Affichage orbital anime de features tournant autour d'un element central avec transitions fluides.
feature-showcasecomplex Both Responsive a11y
boldplayfulsaasagencycentered
Theme
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Zap,
Shield,
BarChart3,
Globe,
Puzzle,
Sparkles,
type LucideIcon,
} from "lucide-react";
const iconMap: Record<string, LucideIcon> = {
Zap,
Shield,
BarChart3,
Globe,
Puzzle,
Sparkles,
};
interface OrbitFeature {
icon: string;
title: string;
description: string;
}
interface FeatureShowcaseOrbitProps {
centralTitle?: string;
centralDescription?: string;
features?: OrbitFeature[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
export default function FeatureShowcaseOrbit({
centralTitle = "Notre plateforme",
centralDescription = "Tout ce dont vous avez besoin, en un seul endroit.",
features = [],
}: FeatureShowcaseOrbitProps) {
const [activeIndex, setActiveIndex] = useState(0);
const count = features.length;
return (
<section
style={{
paddingTop: "var(--section-padding-y)",
paddingBottom: "var(--section-padding-y)",
background: "var(--color-background)",
overflow: "hidden",
}}
>
<div
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
}}
>
{/* Desktop: Orbit layout */}
<div
style={{
position: "relative",
width: "100%",
maxWidth: 700,
margin: "0 auto",
aspectRatio: "1",
}}
>
{/* Orbit rings */}
{[1, 2].map((ring) => (
<div
key={ring}
style={{
position: "absolute",
inset: ring === 1 ? "15%" : "5%",
borderRadius: "50%",
border: `1px solid var(--color-border)`,
opacity: ring === 1 ? 0.6 : 0.3,
}}
/>
))}
{/* Rotating orbit line */}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
style={{
position: "absolute",
inset: "5%",
borderRadius: "50%",
border: "1px dashed var(--color-accent)",
opacity: 0.3,
}}
/>
{/* Central hub */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: EASE }}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "40%",
textAlign: "center",
zIndex: 2,
}}
>
<h2
style={{
fontSize: "clamp(1.25rem, 2.5vw, 1.75rem)",
fontWeight: 700,
color: "var(--color-foreground)",
marginBottom: "0.5rem",
}}
>
{centralTitle}
</h2>
<p
style={{
fontSize: "clamp(0.75rem, 1.5vw, 0.875rem)",
color: "var(--color-foreground-muted)",
lineHeight: 1.5,
}}
>
{centralDescription}
</p>
</motion.div>
{/* Orbiting feature nodes */}
{features.map((feature, i) => {
const angle = (360 / count) * i - 90;
const rad = (angle * Math.PI) / 180;
const radius = 42;
const x = 50 + radius * Math.cos(rad);
const y = 50 + radius * Math.sin(rad);
const Icon = iconMap[feature.icon] || Zap;
const isActive = i === activeIndex;
return (
<motion.button
key={i}
onClick={() => setActiveIndex(i)}
initial={{ scale: 0, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.08 * i, ease: EASE }}
whileHover={{ scale: 1.15 }}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
transform: "translate(-50%, -50%)",
width: isActive ? 72 : 56,
height: isActive ? 72 : 56,
borderRadius: "50%",
background: isActive
? "var(--color-accent)"
: "var(--color-background-card)",
border: `2px solid ${isActive ? "var(--color-accent)" : "var(--color-border)"}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 3,
transition: "all 0.3s ease",
boxShadow: isActive
? "0 0 24px color-mix(in srgb, var(--color-accent) 40%, transparent)"
: "none",
}}
>
<Icon
style={{
width: isActive ? 28 : 22,
height: isActive ? 28 : 22,
color: isActive
? "var(--color-background)"
: "var(--color-foreground-muted)",
transition: "all 0.3s ease",
}}
/>
</motion.button>
);
})}
</div>
{/* Active feature detail card */}
{features[activeIndex] && (
<AnimatePresence mode="wait">
<motion.div
key={activeIndex}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.35, ease: EASE }}
style={{
maxWidth: 520,
margin: "2.5rem auto 0",
textAlign: "center",
padding: "1.5rem 2rem",
borderRadius: "var(--radius-lg)",
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: 700,
color: "var(--color-foreground)",
marginBottom: "0.5rem",
}}
>
{features[activeIndex].title}
</h3>
<p
style={{
fontSize: "0.9375rem",
color: "var(--color-foreground-muted)",
lineHeight: 1.6,
}}
>
{features[activeIndex].description}
</p>
</motion.div>
</AnimatePresence>
)}
{/* Mobile: dot navigation */}
<div
style={{
display: "flex",
justifyContent: "center",
gap: "0.5rem",
marginTop: "1.5rem",
}}
>
{features.map((_, i) => (
<button
key={i}
onClick={() => setActiveIndex(i)}
style={{
width: 8,
height: 8,
borderRadius: "50%",
border: "none",
cursor: "pointer",
background:
i === activeIndex
? "var(--color-accent)"
: "var(--color-border)",
transition: "background 0.2s",
}}
/>
))}
</div>
</div>
</section>
);
}