Retour au catalogue
Pricing 3D Flip
Cards pricing avec 3D flip au hover. Le verso revele les details et le CTA. Perspective et backface-visibility.
pricingcomplex Both Responsive a11y
boldplayfulsaasagencyuniversalgrid
Theme
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { Check, ArrowRight } from "lucide-react";
interface PricingPlan {
id: string; name: string; price: string; period: string; description: string;
features: string[]; detailsTitle: string; details: string[];
ctaLabel: string; ctaUrl: string; highlighted?: boolean;
}
interface Pricing3dFlipProps {
title?: string; subtitle?: string; badge?: string; plans?: PricingPlan[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
const listItem: React.CSSProperties = { display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.9rem", color: "var(--color-foreground-muted)", marginBottom: "0.625rem" };
const checkStyle: React.CSSProperties = { width: 16, height: 16, color: "var(--color-accent)", flexShrink: 0 };
const sectionHeader = (badge: string, title: string, subtitle: string) => (
<div style={{ textAlign: "center", marginBottom: "3.5rem" }}>
{badge && (
<motion.span initial={{ opacity: 0, y: 8 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.4, ease: EASE }}
style={{ display: "inline-block", padding: "0.5rem 1.25rem", borderRadius: "var(--radius-full)", border: "1px solid var(--color-accent-border)", background: "var(--color-accent-subtle)", fontSize: "0.8125rem", fontWeight: 500, color: "var(--color-foreground-muted)", marginBottom: "1.25rem" }}>{badge}</motion.span>
)}
<motion.h2 initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: 0.05, ease: EASE }}
style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(1.75rem, 3.5vw, 3rem)", fontWeight: 700, letterSpacing: "-0.02em", color: "var(--color-foreground)", marginBottom: "0.75rem" }}>{title}</motion.h2>
<motion.p initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ duration: 0.45, delay: 0.1 }}
style={{ fontSize: "1.0625rem", color: "var(--color-foreground-muted)" }}>{subtitle}</motion.p>
</div>
);
function FlipCard({ plan, index }: { plan: PricingPlan; index: number }) {
const [flipped, setFlipped] = useState(false);
const cardBase: React.CSSProperties = {
position: "absolute", inset: 0, borderRadius: "var(--radius-xl)", padding: "2rem",
display: "flex", flexDirection: "column", backfaceVisibility: "hidden",
border: plan.highlighted ? "2px solid var(--color-accent)" : "1px solid var(--color-border)",
background: "var(--color-background-card)",
};
const ctaStyle: React.CSSProperties = {
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px",
padding: "0.875rem 1.5rem", borderRadius: "var(--radius-full)", fontWeight: 600, fontSize: "0.9375rem",
textDecoration: "none", color: "var(--color-foreground)", transition: "all var(--duration-normal) var(--ease-out)",
background: plan.highlighted ? "var(--color-accent)" : "transparent",
border: plan.highlighted ? "none" : "1px solid var(--color-border)",
};
return (
<motion.div initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1, ease: EASE }}
style={{ perspective: 1200, cursor: "pointer", minHeight: 440 }}
onMouseEnter={() => setFlipped(true)} onMouseLeave={() => setFlipped(false)}>
<div style={{ position: "relative", width: "100%", height: "100%", minHeight: 440, transformStyle: "preserve-3d", transition: "transform 0.7s cubic-bezier(0.16, 1, 0.3, 1)", transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)" }}>
<div style={cardBase}>
{plan.highlighted && (
<span style={{ position: "absolute", top: -1, left: "50%", transform: "translateX(-50%)", padding: "0.25rem 1rem", borderRadius: "0 0 var(--radius-md) var(--radius-md)", background: "var(--color-accent)", color: "var(--color-foreground)", fontSize: "0.75rem", fontWeight: 600 }}>Populaire</span>
)}
<h3 style={{ fontFamily: "var(--font-sans)", fontSize: "1.25rem", fontWeight: 600, color: "var(--color-foreground)", marginBottom: "0.5rem", marginTop: plan.highlighted ? "1rem" : 0 }}>{plan.name}</h3>
<p style={{ fontSize: "0.875rem", color: "var(--color-foreground-muted)", marginBottom: "1.5rem" }}>{plan.description}</p>
<div style={{ marginBottom: "1.5rem" }}>
<span style={{ fontFamily: "var(--font-sans)", fontSize: "2.5rem", fontWeight: 700, color: "var(--color-foreground)", letterSpacing: "-0.02em" }}>{plan.price}</span>
<span style={{ fontSize: "0.875rem", color: "var(--color-foreground-muted)", marginLeft: 4 }}>{plan.period}</span>
</div>
<ul style={{ listStyle: "none", padding: 0, margin: 0, flex: 1 }}>
{plan.features.map((f, i) => <li key={i} style={listItem}><Check style={checkStyle} />{f}</li>)}
</ul>
<p style={{ fontSize: "0.75rem", color: "var(--color-foreground-muted)", opacity: 0.6, marginTop: "1rem", textAlign: "center" }}>Survolez pour les details</p>
</div>
<div style={{ ...cardBase, transform: "rotateY(180deg)", justifyContent: "center" }}>
<h4 style={{ fontFamily: "var(--font-sans)", fontSize: "1.125rem", fontWeight: 600, color: "var(--color-foreground)", marginBottom: "1.25rem" }}>{plan.detailsTitle}</h4>
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1.5rem" }}>
{plan.details.map((d, i) => <li key={i} style={{ ...listItem, alignItems: "flex-start", marginBottom: "0.75rem", lineHeight: 1.5 }}><Check style={{ ...checkStyle, marginTop: 2 }} />{d}</li>)}
</ul>
<a href={plan.ctaUrl} style={ctaStyle}>{plan.ctaLabel}<ArrowRight style={{ width: 16, height: 16 }} /></a>
</div>
</div>
</motion.div>
);
}
export default function Pricing3dFlip({ title = "Choisissez votre formule", subtitle = "Des tarifs transparents, sans surprise", badge = "Tarifs", plans = [] }: Pricing3dFlipProps) {
return (
<section style={{ position: "relative", overflow: "hidden", paddingTop: "var(--section-padding-y-lg)", paddingBottom: "var(--section-padding-y-lg)", background: "var(--color-background)" }}>
<div style={{ maxWidth: "var(--container-max-width)", margin: "0 auto", padding: "0 var(--container-padding-x)" }}>
{sectionHeader(badge, title, subtitle)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "1.5rem" }}>
{plans.map((plan, i) => <FlipCard key={plan.id} plan={plan} index={i} />)}
</div>
</div>
</section>
);
}