Skip to content
← Blocks

Pricing Table

Compare plan features and monthly or annual billing in one view.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/pricing-table.json

Use it

Scroll horizontally for long lines
"use client";
import { PricingTable } from "@/components/jez-ui/blocks/pricing-table";

export default function Example() {
  return (
    <>
      <PricingTable />
    </>
  );
}

Compose your own

These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.

Scroll horizontally for long lines
"use client";
import {
  PricingTable,
  PricingTableHeader,
  PricingTableTitle,
  PricingTableLead,
  PricingTableBillingToggle,
  PricingTableContent,
  PricingTableItem,
  PricingTableItemTitle,
  PricingTableItemDescription,
  PricingTablePrice,
  PricingTableFeatures,
} from "@/components/jez-ui/blocks/pricing-table";

export default function PricingTableExample() {
  return (
    <PricingTable formatPrice={(amount) => `$${amount}`}>
      <PricingTableHeader>
        <div>
          <PricingTableTitle>A plan for your studio.</PricingTableTitle>
          <PricingTableLead>Choose how you want to work.</PricingTableLead>
        </div>
      </PricingTableHeader>
      <PricingTableContent className="md:grid-cols-1">
        <PricingTableItem>
          <PricingTableItemTitle>Independent</PricingTableItemTitle>
          <PricingTableItemDescription>
            For small teams with big ideas.
          </PricingTableItemDescription>
          <PricingTablePrice amount={12} annualAmount={9} />
          <a href="#join">Join the studio</a>
          <PricingTableFeatures>
            <li>Unlimited projects</li>
            <li>Your own domain</li>
          </PricingTableFeatures>
        </PricingTableItem>
      </PricingTableContent>
      <PricingTableBillingToggle className="mt-6">
        Pay annually
      </PricingTableBillingToggle>
    </PricingTable>
  );
}
Scroll horizontally for long lines
import { PricingTable, PricingTableHeader, PricingTableTitle, PricingTableContent, PricingTableItemTitle, PricingTableDescription, PricingTableItem, PricingTableLead, PricingTableItemDescription, PricingTableFeatures, PricingTableBillingToggle, PricingTablePrice } from "@/components/jez-ui/blocks/pricing-table";
Read the composition guide →

Props & types

Scroll horizontally for long lines
export type PricingPlan = {
  name: string;
  price: number;
  annualPrice?: number;
  text: React.ReactNode;
  features: React.ReactNode[];
};

export type PricingTableOptions = {
  annual?: boolean;
  defaultAnnual?: boolean;
  onAnnualChange?: (annual: boolean) => void;
  annualDiscount?: number;
  formatPrice?: (amount: number) => React.ReactNode;
  renderAction?: (plan: PricingPlan, annual: boolean) => React.ReactNode;
  className?: string;
  href?: string;
  plans?: typeof defaultPlans;
  heading?: React.ReactNode;
  description?: React.ReactNode;
};

export type PricingTableProps = Omit<
  React.ComponentProps<"section">,
  keyof PricingTableOptions
> &
  PricingTableOptions;

PricingTableProps

Source

View 5 source files

registry/blocks/pricing-table.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Check, ArrowUpRight } from "lucide-react";
import { useControllable } from "../ui/use-controllable";
import { cn } from "../ui/utils";
import { Button } from "../ui/button";
import { Switch } from "../ui/switch";
type PricingTableState = {
  annual: boolean;
  setAnnual: (annual: boolean) => void;
  annualDiscount: number;
  formatPrice: (amount: number) => React.ReactNode;
};
const PricingTableContext = React.createContext<PricingTableState | null>(null);
function usePricingTable() {
  const context = React.useContext(PricingTableContext);
  if (!context)
    throw new Error(
      "PricingTableBillingToggle and PricingTablePrice must be inside PricingTable.",
    );
  return context;
}
export type PricingPlan = {
  name: string;
  price: number;
  annualPrice?: number;
  text: React.ReactNode;
  features: React.ReactNode[];
};
const defaultPlans: PricingPlan[] = [
  {
    name: "Personal",
    price: 0,
    text: "For your own projects and the occasional collaborator.",
    features: [
      "3 active projects",
      "1 guest",
      "7-day version history",
      "Community support",
    ],
  },
  {
    name: "Team",
    price: 15,
    text: "For a team planning, reviewing, and shipping together.",
    features: [
      "Unlimited projects",
      "10 guests",
      "90-day version history",
      "Email support",
    ],
  },
  {
    name: "Studio",
    price: 35,
    text: "For several teams and a longer view of the work.",
    features: [
      "Unlimited projects",
      "Unlimited guests",
      "Unlimited version history",
      "Priority support",
    ],
  },
];
export type PricingTableOptions = {
  annual?: boolean;
  defaultAnnual?: boolean;
  onAnnualChange?: (annual: boolean) => void;
  annualDiscount?: number;
  formatPrice?: (amount: number) => React.ReactNode;
  renderAction?: (plan: PricingPlan, annual: boolean) => React.ReactNode;
  className?: string;
  href?: string;
  plans?: typeof defaultPlans;
  heading?: React.ReactNode;
  description?: React.ReactNode;
};
export type PricingTableProps = Omit<
  React.ComponentProps<"section">,
  keyof PricingTableOptions
> &
  PricingTableOptions;

export function PricingTable({
  annual: controlledAnnual,
  defaultAnnual = false,
  onAnnualChange,
  annualDiscount = 0.2,
  formatPrice = (amount) => `£${amount}`,
  renderAction,
  plans = defaultPlans,
  heading = <>Room for your team.</>,
  description = <>Illustrative pricing. No purchase is processed.</>,
  className,
  href = "#contact",
  children,
  ...rootProps
}: PricingTableProps) {
  const [annual, setAnnual] = useControllable(
    controlledAnnual,
    defaultAnnual,
    onAnnualChange,
  );
  return (
    <section {...rootProps} className={cn("py-12 md:py-16", className)}>
      <PricingTableContext.Provider
        value={{ annual, setAnnual, annualDiscount, formatPrice }}
      >
        {children !== undefined ? (
          children
        ) : (
          <>
            <PricingTableHeader>
              <div>
                <PricingTableTitle>{heading}</PricingTableTitle>
                <PricingTableLead>
                  Start with a project. Choose a plan around the people and the
                  history you need to keep.
                </PricingTableLead>
              </div>
              <PricingTableBillingToggle />
            </PricingTableHeader>
            <PricingTableContent>
              {plans.map((p, i) => (
                <PricingTableItem
                  key={p.name}
                  className={cn(i === 1 && "bg-muted/55")}
                >
                  <div className="flex items-center justify-between">
                    <PricingTableItemTitle>{p.name}</PricingTableItemTitle>
                    {i === 1 && (
                      <span className="text-[11px] text-primary">
                        For teams
                      </span>
                    )}
                  </div>
                  <PricingTableItemDescription>
                    {p.text}
                  </PricingTableItemDescription>
                  <PricingTablePrice
                    amount={p.price}
                    annualAmount={p.annualPrice}
                  />
                  {renderAction ? (
                    renderAction(p, annual)
                  ) : (
                    <Button
                      asChild
                      variant={i === 1 ? "primary" : "outline"}
                      className="w-full justify-between"
                    >
                      <a href={href}>
                        Choose {p.name}
                        <ArrowUpRight size={15} />
                      </a>
                    </Button>
                  )}
                  <PricingTableFeatures>
                    {p.features.map((f) => (
                      <li
                        key={typeof f === "string" ? f : p.features.indexOf(f)}
                        className="flex items-center gap-2.5"
                      >
                        <Check size={14} className="text-primary" />
                        {f}
                      </li>
                    ))}
                  </PricingTableFeatures>
                </PricingTableItem>
              ))}
            </PricingTableContent>
            <PricingTableDescription>{description}</PricingTableDescription>
          </>
        )}
      </PricingTableContext.Provider>
    </section>
  );
}

export function PricingTableHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="pricing-table-header"
      className={cn(
        "mb-10 flex flex-wrap items-end justify-between gap-7",
        className,
      )}
      {...props}
    />
  );
}
export function PricingTableTitle({
  className,
  ...props
}: React.ComponentProps<"h2">) {
  return (
    <h2
      data-slot="pricing-table-title"
      className={cn("text-4xl md:text-5xl", className)}
      {...props}
    />
  );
}
export function PricingTableContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="pricing-table-content"
      className={cn(
        "grid overflow-hidden rounded-xl border border-border md:grid-cols-3",
        className,
      )}
      {...props}
    />
  );
}
export function PricingTableItemTitle({
  className,
  ...props
}: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="pricing-table-itemtitle"
      className={cn("text-lg font-medium", className)}
      {...props}
    />
  );
}
export function PricingTableDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="pricing-table-description"
      className={cn("mt-4 text-xs text-muted-foreground", className)}
      {...props}
    />
  );
}

export function PricingTableItem({
  className,
  ...props
}: React.ComponentProps<"article">) {
  return (
    <article
      data-slot="pricing-table-item"
      className={cn(
        "flex flex-col border-b border-border p-6 last:border-0 md:border-r md:border-b-0 md:p-8",
        className,
      )}
      {...props}
    />
  );
}

export function PricingTableLead({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="pricing-table-lead"
      className={cn(
        "mt-4 max-w-md text-sm leading-relaxed text-muted-foreground",
        className,
      )}
      {...props}
    />
  );
}
export function PricingTableItemDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="pricing-table-item-description"
      className={cn(
        "mb-6 mt-3 min-h-12 text-sm leading-relaxed text-muted-foreground",
        className,
      )}
      {...props}
    />
  );
}
export function PricingTableFeatures({
  className,
  ...props
}: React.ComponentProps<"ul">) {
  return (
    <ul
      data-slot="pricing-table-features"
      className={cn(
        "mt-7 grid gap-4 border-t border-border pt-6 text-xs",
        className,
      )}
      {...props}
    />
  );
}
export function PricingTableBillingToggle({
  className,
  children,
  ...props
}: React.ComponentProps<"label">) {
  const { annual, setAnnual, annualDiscount } = usePricingTable();
  return (
    <label
      data-slot="pricing-table-billing-toggle"
      className={cn("flex items-center gap-3 text-xs", className)}
      {...props}
    >
      <Switch checked={annual} onCheckedChange={setAnnual} />
      {children === undefined ? (
        <>
          Annual billing
          <span className="rounded-full bg-primary/10 px-2 py-1 font-medium text-primary">
            Save {Math.round(annualDiscount * 100)}%
          </span>
        </>
      ) : (
        children
      )}
    </label>
  );
}
export function PricingTablePrice({
  amount,
  annualAmount,
  suffix = "/ month",
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  amount: number;
  annualAmount?: number;
  suffix?: React.ReactNode;
}) {
  const { annual, annualDiscount, formatPrice } = usePricingTable();
  const monthly = annual
    ? (annualAmount ?? amount * (1 - annualDiscount))
    : amount;
  return (
    <div
      data-slot="pricing-table-price"
      aria-live="polite"
      className={className}
      {...props}
    >
      <p className="text-5xl tabular-nums">
        {formatPrice(monthly)}
        <span className="ml-1 text-xs text-muted-foreground">{suffix}</span>
      </p>
      <p className="mb-7 mt-2 text-xs text-muted-foreground">
        {children === undefined ? (
          amount === 0 ? (
            "Free, with no billing details"
          ) : annual ? (
            <>{formatPrice(monthly * 12)} billed annually</>
          ) : (
            "Billed monthly"
          )
        ) : (
          children
        )}
      </p>
    </div>
  );
}

registry/ui/use-controllable.ts

Scroll horizontally for long lines
"use client";
import { useRef, useState, type SetStateAction } from "react";
/** Controlled or instance-local state. Functional updates also work before a rerender. */
export function useControllable<T>(
  value: T | undefined,
  initial: T,
  onChange?: (value: T) => void,
) {
  const [local, setLocal] = useState(initial);
  const current = useRef(value === undefined ? local : value);
  current.current = value === undefined ? local : value;
  return [
    current.current,
    (action: SetStateAction<T>) => {
      const next =
        typeof action === "function"
          ? (action as (previous: T) => T)(current.current)
          : action;
      current.current = next;
      if (value === undefined) setLocal(next);
      onChange?.(next);
    },
  ] as const;
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

registry/ui/button.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
import { LoaderCircle } from "lucide-react";
import { cn } from "./utils";
export type ButtonProps = React.ComponentProps<"button"> & {
  variant?: "primary" | "secondary" | "outline" | "ghost" | "danger";
  size?: "sm" | "md" | "lg";
  loading?: boolean;
  asChild?: boolean;
};
export function Button({
  variant = "primary",
  size = "md",
  loading,
  asChild,
  className,
  children,
  disabled,
  ...props
}: ButtonProps) {
  const Comp = asChild ? Slot.Root : "button";
  return (
    <Comp
      type={asChild ? undefined : "button"}
      disabled={asChild ? undefined : disabled || loading}
      aria-disabled={disabled || loading || undefined}
      aria-busy={loading || undefined}
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium leading-none transition-[background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[.98] disabled:pointer-events-none disabled:opacity-45 aria-disabled:pointer-events-none [&_svg]:shrink-0",
        {
          "bg-primary text-primary-foreground shadow-[inset_0_1px_0_#ffffff26,0_1px_2px_#00000012] hover:bg-primary/90":
            variant === "primary",
          "border-border/60 bg-muted text-foreground hover:bg-muted/70":
            variant === "secondary",
          "border-border bg-background text-foreground shadow-sm hover:bg-muted/60":
            variant === "outline",
          "text-muted-foreground hover:bg-muted hover:text-foreground":
            variant === "ghost",
          "bg-danger text-danger-foreground hover:bg-danger/90":
            variant === "danger",
        },
        {
          "h-8 px-3 text-xs": size === "sm",
          "h-10 px-4": size === "md",
          "h-12 px-6": size === "lg",
        },
        className,
      )}
      {...props}
    >
      {asChild ? (
        children
      ) : (
        <>
          <span
            className={cn(
              "inline-flex min-w-0 flex-1 items-center justify-center gap-2",
              loading && "invisible",
            )}
          >
            {children}
          </span>
          {loading && (
            <LoaderCircle
              aria-hidden="true"
              className="absolute size-4 animate-spin motion-reduce:animate-none"
            />
          )}
        </>
      )}
    </Comp>
  );
}

registry/ui/switch.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Switch as P } from "radix-ui";
import { cn } from "./utils";
export function Switch({
  className,
  ...props
}: React.ComponentProps<typeof P.Root>) {
  return (
    <P.Root
      className={cn(
        "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-border bg-muted p-[2px] align-middle transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary disabled:opacity-50",
        className,
      )}
      {...props}
    >
      <P.Thumb className="pointer-events-none block size-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ease-out data-[state=checked]:translate-x-5" />
    </P.Root>
  );
}

Dependencies

lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0 · radix-ui@1.6.7

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →