Retour au catalogue

Product Showcase 360

Produit qui tourne au drag horizontal avec rotateY + spring. Badge 360 glow, specs en grille overlay.

product-showcasecomplex Both Responsive a11y
boldelegantecommercesaasuniversalcentered
Theme
"use client";

import { useRef } from "react";
import { motion, useScroll, useTransform, useMotionValue, useSpring } from "framer-motion";

interface Spec {
  label: string;
  value: string;
}

interface ProductShowcase360Props {
  badge?: string;
  title?: string;
  subtitle?: string;
  description?: string;
  specs?: Spec[];
  imageSrc?: string;
  imageAlt?: string;
}

const EASE = [0.16, 1, 0.3, 1] as const;

export default function ProductShowcase360({
  badge = "Vue 360",
  title = "Nom du produit",
  subtitle = "Explorez chaque angle",
  description = "Description du produit",
  specs = [],
  imageSrc = "",
  imageAlt = "Produit",
}: ProductShowcase360Props) {
  const sectionRef = useRef<HTMLElement>(null);
  const dragX = useMotionValue(0);
  const smoothX = useSpring(dragX, { stiffness: 200, damping: 30 });
  const rotation = useTransform(smoothX, [-200, 200], [-25, 25]);
  const scaleVal = useTransform(smoothX, [-200, 0, 200], [0.95, 1, 0.95]);

  const { scrollYProgress } = useScroll({
    target: sectionRef,
    offset: ["start end", "end start"],
  });
  const scrollRotation = useTransform(scrollYProgress, [0, 1], [-15, 15]);

  return (
    <section
      ref={sectionRef}
      style={{
        padding: "var(--section-padding-y-lg) 0",
        background: "var(--color-background)",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
          textAlign: "center",
        }}
      >
        {/* Header */}
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, ease: EASE }}
        >
          {badge && (
            <span
              style={{
                display: "inline-flex",
                alignItems: "center",
                gap: "6px",
                padding: "0.4rem 1rem",
                borderRadius: "var(--radius-full)",
                background: "color-mix(in srgb, var(--color-accent) 15%, transparent)",
                fontSize: "0.8125rem",
                fontWeight: 600,
                color: "var(--color-accent)",
                marginBottom: "1.5rem",
              }}
            >
              <span style={{
                width: "6px",
                height: "6px",
                borderRadius: "50%",
                background: "var(--color-accent)",
                boxShadow: "0 0 8px var(--color-accent)",
              }} />
              {badge}
            </span>
          )}
          <h2
            style={{
              fontFamily: "var(--font-sans)",
              fontSize: "clamp(2rem, 4vw, 3.5rem)",
              fontWeight: 800,
              lineHeight: 1.08,
              letterSpacing: "-0.03em",
              color: "var(--color-foreground)",
              marginBottom: "0.5rem",
            }}
          >
            {title}
          </h2>
          <p
            style={{
              fontFamily: "var(--font-serif)",
              fontStyle: "italic",
              fontSize: "1.125rem",
              color: "var(--color-foreground-muted)",
              marginBottom: "2.5rem",
            }}
          >
            {subtitle}
          </p>
        </motion.div>

        {/* 360 Product viewer */}
        <motion.div
          initial={{ opacity: 0, scale: 0.9 }}
          whileInView={{ opacity: 1, scale: 1 }}
          viewport={{ once: true }}
          transition={{ duration: 0.7, ease: EASE }}
          style={{
            position: "relative",
            maxWidth: "500px",
            margin: "0 auto 3rem",
            cursor: "grab",
          }}
        >
          <motion.div
            drag="x"
            dragConstraints={{ left: -200, right: 200 }}
            dragElastic={0.1}
            onDrag={(_, info) => dragX.set(info.offset.x)}
            onDragEnd={() => dragX.set(0)}
            style={{
              rotateY: rotation,
              scale: scaleVal,
              perspective: "800px",
            }}
          >
            <div
              style={{
                aspectRatio: "1",
                borderRadius: "var(--radius-xl)",
                overflow: "hidden",
                background: "var(--color-background-alt)",
                border: "1px solid var(--color-border)",
              }}
            >
              {imageSrc ? (
                <img
                  src={imageSrc}
                  alt={imageAlt}
                  style={{ width: "100%", height: "100%", objectFit: "contain", pointerEvents: "none" }}
                />
              ) : (
                <div
                  style={{
                    width: "100%",
                    height: "100%",
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    background: `radial-gradient(circle, color-mix(in srgb, var(--color-accent) 8%, transparent), transparent 60%)`,
                  }}
                >
                  <motion.div
                    style={{ rotateY: scrollRotation }}
                  >
                    <div
                      style={{
                        width: "180px",
                        height: "180px",
                        borderRadius: "var(--radius-lg)",
                        border: "2px dashed var(--color-border)",
                        display: "flex",
                        alignItems: "center",
                        justifyContent: "center",
                        color: "var(--color-foreground-muted)",
                        fontSize: "0.875rem",
                      }}
                    >
                      Glissez pour tourner
                    </div>
                  </motion.div>
                </div>
              )}
            </div>
          </motion.div>

          {/* Drag hint */}
          <motion.p
            initial={{ opacity: 0 }}
            whileInView={{ opacity: 1 }}
            viewport={{ once: true }}
            transition={{ delay: 0.5, duration: 0.4 }}
            style={{
              marginTop: "1rem",
              fontSize: "0.8125rem",
              color: "var(--color-foreground-muted)",
              opacity: 0.6,
            }}
          >
            Glissez horizontalement pour explorer
          </motion.p>
        </motion.div>

        {/* Description */}
        <motion.p
          initial={{ opacity: 0, y: 12 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, ease: EASE }}
          style={{
            fontSize: "1.0625rem",
            lineHeight: 1.7,
            color: "var(--color-foreground-muted)",
            maxWidth: "520px",
            margin: "0 auto 2.5rem",
          }}
        >
          {description}
        </motion.p>

        {/* Specs grid */}
        {specs.length > 0 && (
          <div
            style={{
              display: "grid",
              gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))",
              gap: "1rem",
              maxWidth: "600px",
              margin: "0 auto",
            }}
          >
            {specs.map((spec, i) => (
              <motion.div
                key={i}
                initial={{ opacity: 0, y: 16 }}
                whileInView={{ opacity: 1, y: 0 }}
                viewport={{ once: true }}
                transition={{ delay: i * 0.08, duration: 0.4, ease: EASE }}
                style={{
                  padding: "1.25rem",
                  borderRadius: "var(--radius-md)",
                  background: "var(--color-background-card)",
                  border: "1px solid var(--color-border)",
                  textAlign: "center",
                }}
              >
                <span style={{ display: "block", fontSize: "1.25rem", fontWeight: 800, color: "var(--color-accent)" }}>
                  {spec.value}
                </span>
                <span style={{ fontSize: "0.8125rem", color: "var(--color-foreground-muted)" }}>
                  {spec.label}
                </span>
              </motion.div>
            ))}
          </div>
        )}
      </div>
    </section>
  );
}

Avis

Product Showcase 360 — React Product-showcase Section — Incubator