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

Avis

Pricing 3D Flip — React Pricing Section — Incubator