Fazekit

Code

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

More in Pricing

View all →