Pricing Table Seven

PreviousNext

A modern pricing table component with contact us plan

Docs
billingsdkblock

Preview

Loading preview…
registry/billingsdk/pricing-table-seven.tsx
"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { cva, type VariantProps } from "class-variance-authority";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Slider } from "@/components/ui/slider";
import { Check, Info } from "lucide-react";
import { cn } from "@/lib/utils";

// Define variants using CVA
const sectionVariants = cva("py-32", {
  variants: {
    size: {
      small: "py-6 md:py-12",
      medium: "py-10 md:py-20",
      large: "py-16 md:py-32",
    },
    theme: {
      minimal: "",
      classic:
        "bg-gradient-to-b from-background to-muted/20 relative overflow-hidden",
    },
  },
  defaultVariants: {
    size: "medium",
    theme: "minimal",
  },
});

const titleVariants = cva("text-pretty text-center font-bold", {
  variants: {
    size: {
      small: "text-3xl lg:text-4xl",
      medium: "text-4xl lg:text-5xl",
      large: "text-4xl lg:text-6xl",
    },
    theme: {
      minimal: "",
      classic:
        "bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const descriptionVariants = cva(
  "text-muted-foreground max-w-2xl mx-auto text-center",
  {
    variants: {
      size: {
        small: "text-base lg:text-lg",
        medium: "text-lg lg:text-xl",
        large: "lg:text-xl",
      },
      theme: {
        minimal: "",
        classic: "",
      },
    },
    defaultVariants: {
      size: "large",
      theme: "minimal",
    },
  },
);

const cardVariants = cva(
  "relative cursor-pointer transition-all duration-300 hover:shadow-lg",
  {
    variants: {
      size: {
        small: "p-4",
        medium: "p-5",
        large: "p-6",
      },
      theme: {
        minimal: "bg-card border-border",
        classic:
          "bg-card border-border/50 hover:shadow-xl backdrop-blur-sm shadow-md",
      },
      selected: {
        true: "",
        false: "",
      },
    },
    compoundVariants: [
      {
        theme: "minimal",
        selected: true,
        className: "border-primary shadow-lg ring-2 ring-primary/50",
      },
      {
        theme: "minimal",
        selected: false,
        className: "hover:border-muted-foreground",
      },
      {
        theme: "classic",
        selected: true,
        className:
          "border-primary/30 shadow-xl ring-1 ring-primary/20 bg-gradient-to-b from-primary/5 to-card",
      },
      {
        theme: "classic",
        selected: false,
        className: "hover:border-primary/20",
      },
    ],
    defaultVariants: {
      size: "large",
      theme: "minimal",
      selected: false,
    },
  },
);

const buttonVariants = cva(
  "w-full transition-all duration-300 hover:cursor-pointer",
  {
    variants: {
      theme: {
        minimal: "",
        classic: "",
      },
      selected: {
        true: "",
        false: "",
      },
    },
    compoundVariants: [
      {
        theme: "minimal",
        selected: true,
        className:
          "shadow hover:bg-primary/90 h-9 py-2 group bg-primary text-primary-foreground ring-primary before:from-primary-foreground/20 after:from-primary-foreground/10 relative isolate inline-flex w-full items-center justify-center overflow-hidden rounded-md px-3 text-sm font-medium ring-1 before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-gradient-to-b before:opacity-80 before:transition-opacity before:duration-300 before:ease-[cubic-bezier(0.4,0.36,0,1)] after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:rounded-md after:bg-gradient-to-b after:to-transparent after:mix-blend-overlay",
      },
      {
        theme: "minimal",
        selected: false,
        className:
          "bg-secondary hover:bg-secondary/80 text-secondary-foreground",
      },
      {
        theme: "classic",
        selected: true,
        className:
          "bg-gradient-to-r from-primary to-primary/80 text-primary-foreground font-semibold hover:shadow-xl active:scale-95 border border-primary/20",
      },
      {
        theme: "classic",
        selected: false,
        className:
          "bg-secondary hover:bg-secondary/80 text-secondary-foreground",
      },
    ],
    defaultVariants: {
      theme: "minimal",
      selected: false,
    },
  },
);

// TypeScript interfaces
export interface PricingTableSevenPlan {
  id: string;
  name: string;
  description: string;
  price: number;
  users: number;
  popular?: boolean;
}

export interface FeatureItemRecord {
  name: string;
  tooltip?: boolean;
  [planId: string]: boolean | string | undefined;
}

export interface FeatureCategory {
  category: string;
  items: FeatureItemRecord[];
}

export interface PricingTableSevenProps extends VariantProps<
  typeof sectionVariants
> {
  plans: PricingTableSevenPlan[];
  features: FeatureCategory[];
  title?: string;
  description?: string;
  onPlanSelect?: (planId: string) => void;
  className?: string;
}

export function PricingTableSeven({
  className,
  plans,
  features,
  title = "Choose a plan that's right for you",
  description = "We believe Untitled should be accessible to all companies, no matter the size of your startup.",
  onPlanSelect,
  size,
  theme = "minimal",
}: PricingTableSevenProps) {
  const [selectedPlan, setSelectedPlan] = useState(
    plans.find((p) => p.popular)?.id || plans[0]?.id || "",
  );

  const [sliderValue, setSliderValue] = useState<number[]>([
    plans.find((p) => p.popular)?.users || 0,
  ]);

  const renderFeatureValue = (value: boolean | string | undefined) => {
    if (typeof value === "boolean") {
      return value ? (
        <Check className="text-primary h-5 w-5" />
      ) : (
        <span className="text-muted-foreground">—</span>
      );
    }
    if (typeof value === "string") {
      return <span className="text-foreground text-sm">{value}</span>;
    }
    return <span className="text-muted-foreground">—</span>;
  };

  const handlePlanSelect = (planId: string) => {
    setSelectedPlan(planId);
    const selected = plans.find((p) => p.id === planId);
    if (selected && typeof selected.users === "number") {
      setSliderValue([selected.users]);
    }
    onPlanSelect?.(planId);
  };

  return (
    <section className={cn(sectionVariants({ size, theme }), className)}>
      {/* Classic theme background elements */}
      {theme === "classic" && (
        <>
          <div className="bg-grid-pattern absolute inset-0 opacity-5" />
          <div className="bg-primary/5 absolute top-1/2 left-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" />
          <div className="bg-secondary/5 absolute top-1/4 right-1/4 h-64 w-64 rounded-full blur-2xl" />
        </>
      )}

      <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
        {/* Hero Section */}
        <div className="pb-1 text-center">
          <h1 className={cn(titleVariants({ size, theme }), "leading-[1.12]")}>
            {title}
          </h1>
          <p className={cn(descriptionVariants({ size, theme }), "mt-6")}>
            {description}
          </p>
        </div>

        {/* User Slider */}
        <div className="mx-auto mt-12 max-w-md px-4">
          <div className="relative">
            <Slider
              value={sliderValue}
              onValueChange={(e) => {
                setSliderValue(e);
                setSelectedPlan(
                  plans.filter((plan) => plan.users >= e[0])[0]?.id ||
                    plans.find((plan) => plan.popular)?.id!,
                );
              }}
              max={25}
              min={1}
              step={1}
              className="text-primary w-full"
            />
            <div className="mt-2 text-center">
              <span className="text-foreground text-sm font-medium">
                {sliderValue[0]} users
              </span>
            </div>
          </div>
        </div>

        {/* Pricing Cards */}
        <div className="mt-16">
          <div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {plans.map((plan) => (
              <motion.div
                key={plan.id}
                initial={{ opacity: 0, y: 20 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.3 }}
              >
                <Card
                  className={cn(
                    cardVariants({
                      size,
                      theme,
                      selected: selectedPlan === plan.id,
                    }),
                  )}
                  onClick={() => handlePlanSelect(plan.id)}
                >
                  {plan.popular && (
                    <Badge
                      className={cn(
                        "absolute -top-3 left-1/2 z-10 -translate-x-1/2 px-4 py-1 text-sm font-medium shadow-lg",
                        theme === "classic"
                          ? "from-primary to-primary/80 text-primary-foreground border-primary/20 bg-gradient-to-r"
                          : "bg-primary text-primary-foreground",
                      )}
                    >
                      Most popular
                    </Badge>
                  )}
                  <CardHeader className="text-center">
                    <CardTitle className="text-lg font-semibold">
                      {plan.name}
                    </CardTitle>
                    <CardDescription className="text-muted-foreground text-sm">
                      {plan.description}
                    </CardDescription>
                    <div className="mt-4">
                      <span
                        className={cn(
                          "text-4xl font-bold",
                          theme === "classic"
                            ? "from-foreground to-muted-foreground bg-gradient-to-r bg-clip-text text-transparent"
                            : "text-foreground",
                        )}
                      >
                        ${plan.price}
                      </span>
                    </div>
                  </CardHeader>
                  <CardContent>
                    <Button
                      className={cn(
                        buttonVariants({
                          theme,
                          selected: selectedPlan === plan.id,
                        }),
                      )}
                    >
                      Get started
                    </Button>
                  </CardContent>
                </Card>
              </motion.div>
            ))}
          </div>

          {/* Feature Comparison Table */}
          <div
            className={cn(
              "overflow-hidden overflow-x-auto rounded-lg border",
              theme === "classic"
                ? "bg-card/50 border-border/50 shadow-md backdrop-blur-sm"
                : "bg-card",
            )}
          >
            <AnimatePresence>
              {features.map((category, categoryIndex) => (
                <motion.div
                  key={category.category}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: 0.3, delay: categoryIndex * 0.1 }}
                >
                  {categoryIndex > 0 && <div className="border-t" />}

                  {/* Category Header */}
                  <div
                    className={cn(
                      "px-4 py-4 sm:px-6",
                      theme === "classic" ? "bg-muted/30" : "bg-muted/50",
                    )}
                  >
                    <h3 className="text-foreground text-sm font-semibold">
                      {category.category}
                    </h3>
                  </div>

                  {/* Feature Rows */}
                  {category.items.map((feature, featureIndex) => (
                    <div
                      key={feature.name}
                      className={cn(
                        "px-4 py-4 sm:px-6",
                        featureIndex > 0 && "border-border/50 border-t",
                      )}
                    >
                      {/* Mobile Layout */}
                      <div className="block sm:hidden">
                        <div className="mb-3 flex items-center space-x-2">
                          <span className="text-foreground text-sm font-medium">
                            {feature.name}
                          </span>
                          {feature.tooltip && (
                            <Info className="text-muted-foreground h-4 w-4" />
                          )}
                        </div>
                        <div className="space-y-2">
                          {plans.map((plan) => (
                            <div
                              key={plan.id}
                              className="flex items-center justify-between"
                            >
                              <span className="text-muted-foreground text-sm">
                                {plan.name}
                              </span>
                              <div className="flex items-center">
                                {renderFeatureValue(feature[plan.id])}
                              </div>
                            </div>
                          ))}
                        </div>
                      </div>

                      {/* Desktop Layout - Match pricing cards grid */}
                      <div className="hidden gap-4 sm:grid sm:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,1fr))] lg:grid-cols-4">
                        <div className="flex items-center space-x-2">
                          <span className="text-foreground text-sm font-medium">
                            {feature.name}
                          </span>
                          {feature.tooltip && (
                            <Info className="text-muted-foreground h-4 w-4" />
                          )}
                        </div>
                        {plans.map((plan) => (
                          <div
                            key={plan.id}
                            className="flex items-center justify-center"
                          >
                            {renderFeatureValue(feature[plan.id])}
                          </div>
                        ))}
                      </div>
                    </div>
                  ))}
                </motion.div>
              ))}
            </AnimatePresence>
          </div>

          {/* Bottom CTA Buttons */}
          <div className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {plans.map((plan) => (
              <Button
                key={plan.id}
                className={cn(
                  buttonVariants({ theme, selected: selectedPlan === plan.id }),
                )}
                onClick={() => handlePlanSelect(plan.id)}
              >
                Get started
              </Button>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

Installation

npx shadcn@latest add @billingsdk/pricing-table-seven

Usage

import { PricingTableSeven } from "@/components/pricing-table-seven"
<PricingTableSeven />