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>
  );
}

Avis

Feature Showcase Orbit — React Feature-showcase Section — Incubator