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>
);
}
Autres variantes product-showcase
Product Showcase Carousel
medium · both
minimalelegant
Product Showcase Comparison
complex · both
corporateminimal
Product Showcase Exploded
complex · both
boldcorporate
Product Showcase Features
medium · both
minimalcorporate
Product Showcase Hero
medium · both
minimalelegant
Product Showcase Scroll
complex · both
minimalcorporate