Pricing Table Two

PreviousNext

A pricing table component with a feature table

Docs
billingsdkblock

Preview

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

import { Check, Minus, Zap } from "lucide-react";
import { useState } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { motion, AnimatePresence } from "motion/react";

import { type Plan } from "@/lib/billingsdk-config";
import { cn } from "@/lib/utils";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";

const sectionVariants = cva("py-32", {
  variants: {
    size: {
      small: "py-12",
      medium: "py-20",
      large: "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("mb-2 font-semibold", {
  variants: {
    size: {
      small: "text-2xl lg:text-3xl",
      medium: "text-3xl lg:text-4xl",
      large: "text-3xl lg:text-5xl",
    },
    theme: {
      minimal: "",
      classic:
        "bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent font-bold",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

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

const toggleWrapperVariants = cva("flex justify-center items-center gap-3", {
  variants: {
    size: {
      small: "mt-6",
      medium: "mt-7",
      large: "mt-8",
    },
    theme: {
      minimal: "",
      classic: "mt-10",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const toggleLabelVariants = cva("font-medium text-sm transition-all", {
  variants: {
    size: {
      small: "text-xs",
      medium: "text-sm",
      large: "text-sm",
    },
    theme: {
      minimal: "",
      classic: "font-semibold",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const switchScaleVariants = cva("transition-all", {
  variants: {
    size: {
      small: "scale-90",
      medium: "scale-95",
      large: "",
    },
    theme: {
      minimal: "",
      classic: "data-[state=checked]:bg-primary",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const plansWrapperVariants = cva("flex", {
  variants: {
    size: {
      small: "mt-6",
      medium: "mt-8",
      large: "mt-10",
    },
    theme: {
      minimal: "",
      classic: "mt-12",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const cardVariants = cva(
  "bg-card text-card-foreground border shadow-sm flex w-full flex-col justify-between text-center rounded-xl md:rounded-none transition-all duration-300",
  {
    variants: {
      size: {
        small: "py-4 gap-6",
        medium: "py-5 gap-7",
        large: "py-6 gap-8",
      },
      theme: {
        minimal: "",
        classic: "hover:shadow-xl backdrop-blur-sm bg-card/50 border-border/50",
      },
      highlight: {
        true: "",
        false: "",
      },
    },
    compoundVariants: [
      {
        theme: "classic",
        highlight: true,
        className:
          "bg-gradient-to-b from-primary/5 to-transparent relative overflow-hidden shadow-2xl",
      },
      {
        theme: "minimal",
        highlight: true,
        className: "bg-muted/30 shadow-lg",
      },
    ],
    defaultVariants: {
      size: "large",
      theme: "minimal",
      highlight: false,
    },
  },
);

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

const priceSubTextVariants = cva("text-muted-foreground", {
  variants: {
    size: {
      small: "mt-2",
      medium: "mt-3",
      large: "mt-3",
    },
    theme: {
      minimal: "",
      classic: "font-medium",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const tableWrapperVariants = cva("relative w-full overflow-x-auto", {
  variants: {
    size: {
      small: "mt-6",
      medium: "mt-8",
      large: "mt-10",
    },
    theme: {
      minimal: "",
      classic:
        "mt-16 bg-card/30 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const featureIconVariants = cva("mx-auto", {
  variants: {
    size: {
      small: "size-4",
      medium: "size-5",
      large: "size-5",
    },
    theme: {
      minimal: "",
      classic: "text-emerald-500",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const firstColWidthVariants = cva("", {
  variants: {
    size: {
      small: "w-[140px]",
      medium: "w-[180px]",
      large: "w-[200px]",
    },
  },
  defaultVariants: {
    size: "large",
  },
});

const buttonVariants = cva(
  "w-full hover:cursor-pointer transition-all duration-300",
  {
    variants: {
      theme: {
        minimal: "",
        classic: "hover:shadow-xl active:scale-95",
      },
    },
    defaultVariants: {
      theme: "minimal",
    },
  },
);

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

export function PricingTableTwo({
  className,
  plans,
  title,
  description,
  onPlanSelect,
  size,
  theme = "minimal",
}: PricingTableTwoProps) {
  const [isAnnually, setIsAnnually] = useState(false);

  function calculateDiscount(
    monthlyPrice: string,
    yearlyPrice: string,
  ): number {
    const monthly = parseFloat(monthlyPrice);
    const yearly = parseFloat(yearlyPrice);

    if (
      monthlyPrice.toLowerCase() === "custom" ||
      yearlyPrice.toLowerCase() === "custom" ||
      isNaN(monthly) ||
      isNaN(yearly) ||
      monthly === 0
    ) {
      return 0;
    }

    const discount = ((monthly * 12 - yearly) / (monthly * 12)) * 100;
    return Math.round(discount);
  }

  const yearlyPriceDiscount = plans.length
    ? Math.max(
        ...plans.map((plan) =>
          calculateDiscount(plan.monthlyPrice, plan.yearlyPrice),
        ),
      )
    : 0;

  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="relative container max-w-5xl">
        <motion.div
          className={cn(
            "flex flex-col items-center gap-4",
            theme === "classic" ? "text-center" : "",
          )}
          initial={{ opacity: 0 }}
          whileInView={{ opacity: 1 }}
          viewport={{ once: true }}
          transition={{ duration: 0.6, ease: "easeOut" }}
        >
          <h2 className={cn(titleVariants({ size, theme }))}>
            {title || "We offer 3 plans"}
          </h2>

          <p className={cn(descriptionVariants({ size, theme }))}>
            {description ||
              "Lorem ipsum dolor sit amet consectetur adipisicing."}
          </p>
        </motion.div>

        {/* Monthly/Yearly Toggle */}
        <div className={cn(toggleWrapperVariants({ size, theme }))}>
          <span
            className={cn(
              toggleLabelVariants({ size, theme }),
              !isAnnually ? "text-foreground" : "text-muted-foreground",
            )}
          >
            Monthly
          </span>
          <Switch
            checked={isAnnually}
            onCheckedChange={setIsAnnually}
            className={cn(switchScaleVariants({ size, theme }))}
          />
          <span
            className={cn(
              toggleLabelVariants({ size, theme }),
              isAnnually ? "text-foreground" : "text-muted-foreground",
            )}
          >
            Yearly
          </span>
        </div>

        <div className="flex justify-center">
          {yearlyPriceDiscount > 0 && (
            <motion.span
              className={cn(
                "text-muted-foreground mt-2 text-xs",
                theme === "classic" && "font-medium text-emerald-500",
              )}
              initial={{ opacity: 0 }}
              whileInView={{ opacity: 1 }}
              viewport={{ once: true }}
              transition={{ duration: 0.5, delay: 0.2 }}
            >
              Save upto {yearlyPriceDiscount}% with yearly plan
            </motion.span>
          )}
        </div>

        <div
          className={cn(
            plansWrapperVariants({ size, theme }),
            "gap-4 md:gap-0",
            plans.length === 1 && "mx-auto max-w-md flex-col",
            plans.length === 2 && "mx-auto max-w-4xl flex-col md:flex-row",
            plans.length >= 3 && "mx-auto max-w-7xl flex-col lg:flex-row",
          )}
        >
          {plans.map((plan: Plan, index: number) => (
            <motion.div
              key={plan.id}
              className={cn(
                cardVariants({
                  size,
                  theme,
                  highlight: plan.highlight,
                }),
                index === 0 && "md:rounded-l-xl md:border-r-0",
                index === plans.length - 1 && "md:rounded-r-xl md:border-l-0",
                index > 0 &&
                  index < plans.length - 1 &&
                  "md:border-r-0 md:border-l-0",
                plans.length === 1 && "rounded-xl",
              )}
              initial={{ opacity: 0 }}
              whileInView={{ opacity: 1 }}
              viewport={{ once: true }}
              transition={{
                duration: 0.6,
                ease: "easeOut",
                delay: index * 0.15,
              }}
            >
              <div className="grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6">
                <div className="flex items-center justify-center gap-2">
                  <div
                    className={cn(
                      "leading-none font-semibold",
                      theme === "classic" && "text-lg font-bold",
                    )}
                  >
                    {plan.title}
                  </div>
                </div>
                <p
                  className={cn(
                    "text-muted-foreground text-center",
                    theme === "classic" && "text-foreground/80",
                  )}
                >
                  {plan.description}
                </p>
              </div>

              <div className="px-6">
                <AnimatePresence mode="wait">
                  {isAnnually ? (
                    <motion.div
                      key="yearly"
                      initial={{ opacity: 0, y: 10 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -10 }}
                      transition={{ duration: 0.3 }}
                    >
                      <span className={cn(priceTextVariants({ size, theme }))}>
                        {parseFloat(plan.yearlyPrice) >= 0 && (
                          <>{plan.currency}</>
                        )}
                        {plan.yearlyPrice}
                        {calculateDiscount(
                          plan.monthlyPrice,
                          plan.yearlyPrice,
                        ) > 0 && (
                          <span
                            className={cn(
                              "ml-2 text-xs",
                              theme === "classic"
                                ? "font-semibold text-emerald-500"
                                : "underline",
                            )}
                          >
                            {calculateDiscount(
                              plan.monthlyPrice,
                              plan.yearlyPrice,
                            )}
                            % off
                          </span>
                        )}
                      </span>
                      <p className={cn(priceSubTextVariants({ size, theme }))}>
                        per year
                      </p>
                    </motion.div>
                  ) : (
                    <motion.div
                      key="monthly"
                      initial={{ opacity: 0, y: 10 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -10 }}
                      transition={{ duration: 0.3 }}
                    >
                      <span className={cn(priceTextVariants({ size, theme }))}>
                        {parseFloat(plan.monthlyPrice) >= 0 && (
                          <>{plan.currency}</>
                        )}
                        {plan.monthlyPrice}
                      </span>
                      <p className={cn(priceSubTextVariants({ size, theme }))}>
                        per month
                      </p>
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>

              <div className="flex items-center px-6">
                <Button
                  className={cn(
                    buttonVariants({ theme }),
                    plan.highlight &&
                      theme === "minimal" &&
                      "focus-visible:ring-ring hover:bg-primary/90 group bg-primary text-primary-foreground ring-primary before:from-primary-foreground/20 after:from-primary-foreground/10 relative isolate inline-flex h-9 w-full items-center justify-center gap-2 overflow-hidden rounded-md px-3 py-2 text-left text-sm font-medium whitespace-nowrap shadow ring-1 transition duration-300 ease-[cubic-bezier(0.4,0.36,0,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 focus-visible:ring-1 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
                    plan.highlight &&
                      theme === "classic" &&
                      "from-primary to-primary/80 text-primary-foreground border-primary/20 relative overflow-hidden rounded-lg border bg-gradient-to-r px-6 py-3 font-semibold",
                  )}
                  variant={plan.highlight ? "default" : "secondary"}
                  onClick={() => onPlanSelect?.(plan.id)}
                >
                  {theme === "classic" && plan.highlight && (
                    <Zap className="mr-1 h-4 w-4" />
                  )}
                  {plan.buttonText}
                  {theme === "classic" && plan.highlight && (
                    <div className="absolute inset-0 translate-x-[-100%] bg-gradient-to-r from-white/0 via-white/10 to-white/0 transition-transform duration-700 hover:translate-x-[100%]" />
                  )}
                </Button>
              </div>
            </motion.div>
          ))}
        </div>

        <motion.div
          className={cn(tableWrapperVariants({ size, theme }))}
          initial={{ opacity: 0 }}
          whileInView={{ opacity: 1 }}
          viewport={{ once: true }}
          transition={{ duration: 0.7, ease: "easeOut" }}
        >
          <Table className={cn(theme === "classic" && "bg-transparent")}>
            <TableHeader>
              <TableRow
                className={cn(theme === "classic" && "border-border/30")}
              >
                <TableHead
                  className={firstColWidthVariants({ size })}
                ></TableHead>
                {plans.map((plan: Plan) => (
                  <TableHead
                    key={plan.id}
                    className={cn(
                      "text-primary text-center font-bold",
                      theme === "classic" && "text-lg",
                    )}
                  >
                    {plan.title}
                  </TableHead>
                ))}
              </TableRow>
            </TableHeader>
            <TableBody>
              {(() => {
                const allFeatures = new Set<string>();
                plans.forEach((plan) => {
                  plan.features.forEach((feature) => {
                    allFeatures.add(feature.name);
                  });
                });
                return Array.from(allFeatures).map(
                  (featureName, featureIndex) => (
                    <TableRow
                      key={featureIndex}
                      className={cn(
                        theme === "classic" &&
                          "border-border/20 hover:bg-muted/30",
                      )}
                    >
                      <TableCell
                        className={cn(
                          "text-left font-medium",
                          theme === "classic" &&
                            "text-foreground/90 font-semibold",
                        )}
                      >
                        {featureName}
                      </TableCell>
                      {plans.map((plan: Plan) => {
                        const feature = plan.features.find(
                          (f) => f.name === featureName,
                        );
                        return (
                          <TableCell key={plan.id} className="text-center">
                            {feature ? (
                              feature.icon === "check" ? (
                                <Check
                                  className={cn(
                                    featureIconVariants({ size, theme }),
                                  )}
                                />
                              ) : feature.icon === "minus" ? (
                                <Minus
                                  className={cn(
                                    featureIconVariants({ size, theme }),
                                  )}
                                />
                              ) : (
                                <span
                                  className={cn(
                                    "text-muted-foreground text-sm",
                                    theme === "classic" &&
                                      "text-foreground/70 font-medium",
                                  )}
                                >
                                  {feature.name}
                                </span>
                              )
                            ) : (
                              <Minus
                                className={cn(
                                  featureIconVariants({ size, theme }),
                                )}
                              />
                            )}
                          </TableCell>
                        );
                      })}
                    </TableRow>
                  ),
                );
              })()}
            </TableBody>
          </Table>
        </motion.div>
      </div>
    </section>
  );
}

Installation

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

Usage

import { PricingTableTwo } from "@/components/pricing-table-two"
<PricingTableTwo />