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