"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { ArrowRight, Check, Users, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
export type UsagePlan = {
id: string;
name: string;
tagline: string;
/** Recommended while events <= maxEvents and seats <= maxSeats. */
maxEvents: number;
maxSeats: number;
base: number;
/** Price per 1,000 events above the included amount. */
perThousand: number;
includedEvents: number;
perSeat: number;
includedSeats: number;
features: string[];
cta: string;
accent: string;
};
export interface PricingSliderProps {
eyebrow?: string;
title?: string;
subtitle?: string;
/** Discrete stops for the events slider. */
eventSteps?: number[];
maxSeats?: number;
plans?: UsagePlan[];
currency?: string;
defaultEventsIndex?: number;
defaultSeats?: number;
onSelect?: (plan: UsagePlan, usage: { events: number; seats: number; price: number }) => void;
className?: string;
}
const STEPS = [10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000, 2_500_000, 5_000_000, 10_000_000];
const PLANS: UsagePlan[] = [
{
id: "launch",
name: "Launch",
tagline: "For products finding their first users.",
maxEvents: 100_000,
maxSeats: 5,
base: 0,
includedEvents: 25_000,
perThousand: 0.4,
includedSeats: 2,
perSeat: 8,
features: ["Real-time event stream", "30-day data retention", "Email alerts", "Community support"],
cta: "Start for free",
accent: "from-sky-500 to-cyan-400",
},
{
id: "growth",
name: "Growth",
tagline: "For teams scaling a product that works.",
maxEvents: 1_000_000,
maxSeats: 20,
base: 79,
includedEvents: 100_000,
perThousand: 0.12,
includedSeats: 5,
perSeat: 12,
features: ["Everything in Launch", "12-month retention", "Funnels & cohorts", "Slack & webhook alerts", "Priority support"],
cta: "Start 14-day trial",
accent: "from-violet-500 to-fuchsia-500",
},
{
id: "scale",
name: "Scale",
tagline: "For high-volume products and larger orgs.",
maxEvents: Number.POSITIVE_INFINITY,
maxSeats: Number.POSITIVE_INFINITY,
base: 399,
includedEvents: 1_000_000,
perThousand: 0.06,
includedSeats: 20,
perSeat: 10,
features: ["Everything in Growth", "Unlimited retention", "SSO & audit logs", "Data warehouse sync", "Dedicated engineer"],
cta: "Talk to sales",
accent: "from-amber-500 to-rose-500",
},
];
function compact(n: number) {
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${+(n / 1_000).toFixed(0)}k`;
return String(n);
}
function priceFor(plan: UsagePlan, events: number, seats: number) {
const extraEvents = Math.max(0, events - plan.includedEvents) / 1000;
const extraSeats = Math.max(0, seats - plan.includedSeats);
return Math.round(plan.base + extraEvents * plan.perThousand + extraSeats * plan.perSeat);
}
/* A native range input (accessible, keyboard/touch friendly) laid over a custom animated track. */
function RangeField({
label,
icon,
value,
min,
max,
valueText,
display,
ticks,
onChange,
}: {
label: string;
icon: React.ReactNode;
value: number;
min: number;
max: number;
valueText: string;
display: React.ReactNode;
ticks: string[];
onChange: (v: number) => void;
}) {
const id = React.useId();
const reduce = useReducedMotion();
const pct = ((value - min) / (max - min)) * 100;
const spring = { type: "spring", stiffness: 500, damping: 40 } as const;
return (
<div>
<div className="flex items-end justify-between gap-4">
<label htmlFor={id} className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<span className="grid size-7 place-items-center rounded-lg bg-primary/10 text-primary">{icon}</span>
{label}
</label>
<span className="text-2xl font-semibold tabular-nums tracking-tight text-foreground">{display}</span>
</div>
<div className="relative mt-5 h-6">
<div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-violet-500 via-fuchsia-500 to-amber-400"
initial={false}
animate={{ width: `${pct}%` }}
transition={reduce ? { duration: 0 } : spring}
/>
</div>
{/* inset matches the native thumb travel so the visual thumb sits under the pointer */}
<div aria-hidden className="pointer-events-none absolute inset-x-2 inset-y-0">
<motion.div
className="absolute top-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-background bg-foreground shadow-lg ring-1 ring-border"
initial={false}
animate={{ left: `${pct}%` }}
transition={reduce ? { duration: 0 } : spring}
/>
</div>
<input
id={id}
type="range"
min={min}
max={max}
step={1}
value={value}
aria-valuetext={valueText}
onChange={(e) => onChange(Number(e.target.value))}
className="peer absolute inset-0 size-full cursor-pointer opacity-0"
/>
<div aria-hidden className="pointer-events-none absolute -inset-x-3 -inset-y-2 rounded-full ring-ring/60 peer-focus-visible:ring-2" />
</div>
<div aria-hidden className="mt-3 flex justify-between text-[11px] font-medium text-muted-foreground">
{ticks.map((t, i) => (
<span key={i}>{t}</span>
))}
</div>
</div>
);
}
function AnimatedPrice({ value, currency }: { value: number; currency: string }) {
const reduce = useReducedMotion();
const spring = useSpring(value, { stiffness: 120, damping: 20, mass: 0.6 });
const text = useTransform(spring, (v) => Math.round(v).toLocaleString("en-US"));
React.useEffect(() => {
if (reduce) spring.jump(value);
else spring.set(value);
}, [value, reduce, spring]);
return (
<span className="inline-flex items-start">
<span className="mr-1 mt-2 text-2xl font-semibold">{currency}</span>
<motion.span>{text}</motion.span>
</span>
);
}
export function PricingSlider({
eyebrow = "Usage-based pricing",
title = "Pay for what you track. Nothing more.",
subtitle = "Drag the sliders to estimate your monthly bill. We'll recommend the plan that fits — you can switch any time.",
eventSteps = STEPS,
maxSeats = 50,
plans = PLANS,
currency = "$",
defaultEventsIndex = 3,
defaultSeats = 8,
onSelect,
className,
}: PricingSliderProps) {
const [eventsIndex, setEventsIndex] = React.useState(defaultEventsIndex);
const [seats, setSeats] = React.useState(defaultSeats);
const events = eventSteps[eventsIndex];
const plan = plans.find((p) => events <= p.maxEvents && seats <= p.maxSeats) ?? plans[plans.length - 1];
const price = priceFor(plan, events, seats);
const eventCost = Math.round(Math.max(0, events - plan.includedEvents) / 1000 * plan.perThousand);
const seatCost = Math.max(0, seats - plan.includedSeats) * plan.perSeat;
const eventTicks = [eventSteps[0], eventSteps[Math.floor((eventSteps.length - 1) / 3)], eventSteps[Math.floor(((eventSteps.length - 1) * 2) / 3)], eventSteps[eventSteps.length - 1]].map(compact);
return (
<section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
<div aria-hidden className="pointer-events-none absolute inset-0 [background-image:radial-gradient(color-mix(in_oklch,var(--foreground)_9%,transparent)_1px,transparent_1px)] [background-size:22px_22px] [mask-image:radial-gradient(70%_60%_at_50%_30%,#000,transparent)]" />
<div className="relative mx-auto max-w-6xl">
<header className="mx-auto max-w-3xl text-center">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
<h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
<p className="mx-auto mt-4 max-w-2xl text-pretty text-base text-muted-foreground sm:text-lg">{subtitle}</p>
</header>
<div className="mt-12 grid gap-6 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
{/* Controls */}
<div className="flex flex-col rounded-3xl border bg-card p-6 shadow-sm sm:p-8">
<div className="space-y-10 lg:mb-10">
<RangeField
label="Monthly tracked events"
icon={<Zap className="size-4" />}
value={eventsIndex}
min={0}
max={eventSteps.length - 1}
valueText={`${events.toLocaleString("en-US")} events per month`}
display={compact(events)}
ticks={eventTicks}
onChange={setEventsIndex}
/>
<RangeField
label="Team seats"
icon={<Users className="size-4" />}
value={seats}
min={1}
max={maxSeats}
valueText={`${seats} ${seats === 1 ? "seat" : "seats"}`}
display={seats}
ticks={["1", String(Math.round(maxSeats / 4)), String(Math.round(maxSeats / 2)), String(Math.round((maxSeats * 3) / 4)), String(maxSeats)]}
onChange={setSeats}
/>
</div>
<dl className="mt-10 grid grid-cols-3 lg:mt-auto gap-3 border-t pt-6 text-sm">
{[
["Platform", plan.base],
["Events", eventCost],
["Seats", seatCost],
].map(([k, v]) => (
<div key={k} className="rounded-2xl bg-muted/60 px-3 py-3 sm:px-4">
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="mt-1 font-semibold tabular-nums text-foreground">
{currency}
{Number(v).toLocaleString("en-US")}
</dd>
</div>
))}
</dl>
</div>
{/* Recommendation */}
<div className="relative overflow-hidden rounded-3xl border bg-card p-6 shadow-sm sm:p-8">
<motion.div
key={plan.id}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
className={cn("pointer-events-none absolute -right-28 -top-28 size-72 rounded-full bg-gradient-to-br opacity-25 blur-3xl dark:opacity-20", plan.accent)}
/>
<div className="relative">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Recommended plan</p>
<div className="mt-2 h-9" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.h3
key={plan.id}
initial={{ opacity: 0, y: 14, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -14, filter: "blur(4px)" }}
transition={{ duration: 0.3 }}
className="flex items-center gap-2 text-2xl font-semibold tracking-tight text-foreground"
>
<span className={cn("size-3 rounded-full bg-gradient-to-br", plan.accent)} />
{plan.name}
</motion.h3>
</AnimatePresence>
</div>
<p className="mt-1 text-sm text-muted-foreground">{plan.tagline}</p>
<div className="mt-6 flex items-end gap-2">
<span className="text-5xl font-semibold leading-none tracking-tight text-foreground sm:text-6xl">
<AnimatedPrice value={price} currency={currency} />
</span>
<span className="pb-1 text-sm text-muted-foreground">/ month</span>
</div>
<p className="sr-only" aria-live="polite">
Estimated {currency}
{price} per month on the {plan.name} plan
</p>
<ul className="mt-6 space-y-2.5 text-sm">
<AnimatePresence mode="popLayout" initial={false}>
{plan.features.map((f, i) => (
<motion.li
key={plan.id + f}
layout
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0, transition: { delay: i * 0.04 } }}
exit={{ opacity: 0, x: 8 }}
className="flex items-center gap-2.5 text-foreground/85"
>
<Check aria-hidden className="size-4 shrink-0 text-primary" strokeWidth={2.5} />
{f}
</motion.li>
))}
</AnimatePresence>
</ul>
<button
type="button"
onClick={() => onSelect?.(plan, { events, seats, price })}
className="group mt-8 inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-primary text-sm font-semibold text-primary-foreground shadow-lg shadow-primary/25 transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{plan.cta}
<ArrowRight aria-hidden className="size-4 transition-transform group-hover:translate-x-0.5" />
</button>
<p className="mt-3 text-center text-xs text-muted-foreground">No credit card required · Cancel anytime</p>
</div>
</div>
</div>
</div>
</section>
);
}