Retour au catalogue

Pricing Calculator

Calculateur de prix interactif avec sliders (utilisateurs, stockage) qui mettent a jour le prix en temps reel avec transitions fluides.

pricingcomplex Both Responsive a11y
minimalcorporatesaasuniversalcentered
Theme
"use client";

import { useState, useMemo } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
import { Calculator, Check } from "lucide-react";
import { useEffect, useRef } from "react";

interface SliderConfig {
  label: string;
  min: number;
  max: number;
  step: number;
  defaultValue: number;
  pricePerUnit: number;
  unit: string;
}

interface PricingCalculatorProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  basePrice?: number;
  currency?: string;
  period?: string;
  sliders?: SliderConfig[];
  includedFeatures?: string[];
  ctaLabel?: string;
}

const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];

function AnimatedPrice({ value, currency }: { value: number; currency: string }) {
  const ref = useRef<HTMLSpanElement>(null);
  const mv = useMotionValue(0);
  const display = useTransform(mv, (v) => Math.round(v));

  useEffect(() => {
    const controls = animate(mv, value, { duration: 0.5, ease: [0.16, 1, 0.3, 1] });
    return controls.stop;
  }, [value, mv]);

  useEffect(() => {
    const unsub = display.on("change", (v) => {
      if (ref.current) ref.current.textContent = `${v}${currency === "EUR" ? "\u20ac" : "$"}`;
    });
    return unsub;
  }, [display, currency]);

  return <span ref={ref} />;
}

export default function PricingCalculator({
  badge = "Tarification",
  title = "Calculez votre prix ideal",
  subtitle = "",
  basePrice = 19,
  currency = "EUR",
  period = "/mois",
  sliders = [],
  includedFeatures = [],
  ctaLabel = "Demarrer",
}: PricingCalculatorProps) {
  const [values, setValues] = useState<number[]>(() => sliders.map((s) => s.defaultValue));

  const totalPrice = useMemo(() => {
    return basePrice + sliders.reduce((sum, s, i) => sum + (values[i] ?? s.defaultValue) * s.pricePerUnit, 0);
  }, [basePrice, sliders, values]);

  return (
    <section className="py-20 lg:py-28" style={{ background: "var(--color-background)" }}>
      <div className="mx-auto max-w-4xl px-6">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.6, ease }}
          viewport={{ once: true }}
          className="text-center mb-14"
        >
          {badge && (
            <span className="inline-flex items-center gap-2 mb-4 text-xs font-medium tracking-widest uppercase" style={{ color: "var(--color-accent)" }}>
              <Calculator size={14} />
              {badge}
            </span>
          )}
          <h2 className="text-3xl md:text-4xl lg:text-5xl font-bold" style={{ color: "var(--color-foreground)" }}>{title}</h2>
          {subtitle && <p className="mt-4 text-lg max-w-2xl mx-auto" style={{ color: "var(--color-foreground-muted)" }}>{subtitle}</p>}
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: 30 }}
          whileInView={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, ease, delay: 0.15 }}
          viewport={{ once: true }}
          className="rounded-2xl p-8 md:p-10"
          style={{ background: "var(--color-background-card)", border: "1px solid var(--color-border)" }}
        >
          <div className="grid md:grid-cols-[1fr_auto] gap-10">
            <div className="flex flex-col gap-8">
              {sliders.map((slider, i) => (
                <div key={slider.label}>
                  <div className="flex justify-between mb-3">
                    <span className="text-sm font-medium" style={{ color: "var(--color-foreground)" }}>{slider.label}</span>
                    <span className="text-sm font-semibold" style={{ color: "var(--color-accent)" }}>
                      {values[i]} {slider.unit}{(values[i] ?? 0) > 1 ? "s" : ""}
                    </span>
                  </div>
                  <input
                    type="range"
                    min={slider.min}
                    max={slider.max}
                    step={slider.step}
                    value={values[i]}
                    onChange={(e) => {
                      const next = [...values];
                      next[i] = Number(e.target.value);
                      setValues(next);
                    }}
                    className="w-full h-2 rounded-full appearance-none cursor-pointer"
                    style={{ background: `linear-gradient(to right, var(--color-accent) ${((values[i] - slider.min) / (slider.max - slider.min)) * 100}%, var(--color-border) ${((values[i] - slider.min) / (slider.max - slider.min)) * 100}%)` }}
                  />
                  <div className="flex justify-between mt-1">
                    <span className="text-xs" style={{ color: "var(--color-foreground-muted)" }}>{slider.min}</span>
                    <span className="text-xs" style={{ color: "var(--color-foreground-muted)" }}>{slider.max}</span>
                  </div>
                </div>
              ))}
            </div>

            <div className="flex flex-col items-center justify-center md:pl-10 md:border-l" style={{ borderColor: "var(--color-border)" }}>
              <span className="text-sm mb-2" style={{ color: "var(--color-foreground-muted)" }}>Votre prix</span>
              <div className="text-5xl md:text-6xl font-bold" style={{ color: "var(--color-foreground)" }}>
                <AnimatedPrice value={totalPrice} currency={currency} />
              </div>
              <span className="text-sm mt-1" style={{ color: "var(--color-foreground-muted)" }}>{period}</span>
            </div>
          </div>

          {includedFeatures.length > 0 && (
            <div className="mt-8 pt-8" style={{ borderTop: "1px solid var(--color-border)" }}>
              <div className="flex flex-wrap gap-4">
                {includedFeatures.map((f) => (
                  <span key={f} className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--color-foreground-muted)" }}>
                    <Check size={14} style={{ color: "var(--color-accent)" }} />
                    {f}
                  </span>
                ))}
              </div>
            </div>
          )}

          <div className="mt-8 text-center">
            <button
              className="px-8 py-3 rounded-full text-sm font-semibold transition-transform hover:scale-105"
              style={{ background: "var(--color-accent)", color: "var(--color-background)" }}
            >
              {ctaLabel}
            </button>
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Avis

Pricing Calculator — React Pricing Section — Incubator