Pricing Table

Previous

A pricing table component with support for monthly/yearly pricing.

Docs
alexcarpenterui

Preview

Loading preview…
registry/default/ui/pricing-table.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Badge } from "@/registry/default/ui/badge"
import { Button } from "@/registry/default/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/registry/default/ui/card"
import {
  InfoList,
  InfoListIcon,
  InfoListItem,
  InfoListText,
} from "@/registry/default/ui/info-list"
import { Switch } from "@/registry/default/ui/switch"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/default/ui/toggle-group"
import NumberFlow from "@number-flow/react"
import { useControllableState } from "@radix-ui/react-use-controllable-state"

type BillingPeriod = "monthly" | "yearly"
export type { BillingPeriod as PricingTableBillingPeriod }

type PriceFormatterOptions = {
  style: "currency"
  currency: string
  trailingZeroDisplay?: "stripIfInteger" | "auto"
  minimumFractionDigits?: number
  maximumFractionDigits?: number
}

type PricingTableContextValue = {
  billingPeriod: BillingPeriod
  setBillingPeriod: (period: BillingPeriod) => void
  priceFormatter: Intl.NumberFormat
  priceFormatterOptions: PriceFormatterOptions
  locale: Intl.LocalesArgument
}

const defaultPriceFormatterOptions: PriceFormatterOptions = {
  style: "currency",
  currency: "USD",
  trailingZeroDisplay: "stripIfInteger",
} as const

const PricingTableContext = React.createContext<PricingTableContextValue>({
  billingPeriod: "monthly",
  setBillingPeriod: () => {},
  priceFormatter: new Intl.NumberFormat("en-US", defaultPriceFormatterOptions),
  priceFormatterOptions: defaultPriceFormatterOptions,
  locale: "en-US",
})

export const usePricingTableContext = () =>
  React.useContext(PricingTableContext)

export function PricingTable({
  billingPeriod: billingPeriodProp,
  setBillingPeriod: setBillingPeriodProp,
  defaultBillingPeriod = "monthly",
  priceFormatterOptions = defaultPriceFormatterOptions,
  locale = "en-US",
  children,
  className,
  ...props
}: React.ComponentProps<"div"> & {
  billingPeriod?: BillingPeriod
  setBillingPeriod?: (period: BillingPeriod) => void
  defaultBillingPeriod?: BillingPeriod
  priceFormatterOptions?: PriceFormatterOptions
  locale?: string
}) {
  const [billingPeriod, setBillingPeriod] = useControllableState({
    prop: billingPeriodProp,
    defaultProp: defaultBillingPeriod,
    onChange: setBillingPeriodProp,
    caller: "pricing-table",
  })

  const priceFormatter = React.useMemo(
    () => new Intl.NumberFormat(locale, priceFormatterOptions),
    [locale, priceFormatterOptions]
  )

  const contextValue = React.useMemo(
    () => ({
      billingPeriod,
      setBillingPeriod,
      priceFormatter,
      priceFormatterOptions,
      locale,
    }),
    [
      billingPeriod,
      setBillingPeriod,
      priceFormatter,
      priceFormatterOptions,
      locale,
    ]
  )

  return (
    <PricingTableContext.Provider value={contextValue}>
      <div
        className={cn("@container/pricing-table w-full", className)}
        {...props}
      >
        {children}
      </div>
    </PricingTableContext.Provider>
  )
}

export function PricingTableSwitchGroup({
  children,
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "mb-16 grid grid-cols-[1fr_theme(spacing.8)_1fr] gap-2",
        className
      )}
      {...props}
    >
      {children}
    </div>
  )
}

export function PricingTableSwitchItem({
  value,
  children,
  className,
  ...props
}: React.ComponentProps<"span"> & {
  value: BillingPeriod
}) {
  const { billingPeriod } = usePricingTableContext()
  return (
    <span
      className={cn(
        "truncate text-sm font-semibold first:text-right",
        {
          "text-primary": billingPeriod === value,
        },
        className
      )}
      data-active={billingPeriod === value}
      {...props}
    >
      {children}
    </span>
  )
}

export function PricingTableSwitch({
  children,
  className,
  ...props
}: React.ComponentProps<typeof Switch>) {
  const { billingPeriod, setBillingPeriod } = usePricingTableContext()
  return (
    <Switch
      className={cn(className)}
      checked={billingPeriod === "yearly"}
      onCheckedChange={(checked) => {
        setBillingPeriod(checked ? "yearly" : "monthly")
      }}
      {...props}
    />
  )
}

export function PricingTableToggleGroup({
  children,
  className,
  ...props
}: Omit<
  React.ComponentProps<typeof ToggleGroup>,
  "type" | "value" | "onValueChange" | "defaultValue"
>) {
  const { billingPeriod, setBillingPeriod } = usePricingTableContext()

  return (
    <ToggleGroup
      type="single"
      value={billingPeriod}
      onValueChange={(value: string) => {
        if (value === "monthly" || value === "yearly") {
          setBillingPeriod(value as BillingPeriod)
        }
      }}
      variant="outline"
      className={cn("mx-auto mb-16", className)}
      {...props}
    >
      {children}
    </ToggleGroup>
  )
}

export function PricingTableToggleItem({
  children,
  value,
  ...props
}: React.ComponentProps<typeof ToggleGroupItem> & {
  value: BillingPeriod
}) {
  return (
    <ToggleGroupItem value={value} {...props}>
      {children}
    </ToggleGroupItem>
  )
}

export function PricingTableGrid({
  children,
  className,
  ...props
}: React.ComponentProps<"div">) {
  const count = React.Children.count(children)
  return (
    <div
      className={cn(
        "group/pricing-table-grid",
        "grid grid-rows-[auto_auto_auto] gap-x-4 gap-y-12",
        "data-[count=1]:grid-cols-1",
        "data-[count=2]:@xl/pricing-table:grid-cols-2",
        "data-[count=3]:@3xl/pricing-table:grid-cols-3",
        "data-[count=4]:@4xl/pricing-table:grid-cols-4",
        className
      )}
      data-count={count}
      {...props}
    >
      {children}
    </div>
  )
}

type PricingTableCardContextValue = {
  highlight?: boolean
}

const PricingTableCardContext =
  React.createContext<PricingTableCardContextValue>({
    highlight: false,
  })

export const usePricingTableCardContext = () =>
  React.useContext(PricingTableCardContext)

export function PricingTableCard({
  highlight,
  children,
  className,
  ...props
}: React.ComponentProps<typeof Card> & { highlight?: boolean }) {
  return (
    <PricingTableCardContext.Provider value={{ highlight }}>
      <Card
        className={cn(
          "group/pricing-table-card relative row-span-3 grid grid-rows-subgrid",
          highlight && [
            "ring-primary border-primary -my-4 py-10 ring",
            "group-data-[count=1]/pricing-table-grid:my-0",
            "group-data-[count=2]/pricing-table-grid:@xl/pricing-table:-my-4 group-data-[count=2]/pricing-table-grid:@xl/pricing-table:py-10",
            "group-data-[count=3]/pricing-table-grid:@3xl/pricing-table:-my-4 group-data-[count=3]/pricing-table-grid:@3xl/pricing-table:py-10",
            "group-data-[count=4]/pricing-table-grid:@4xl/pricing-table:-my-4 group-data-[count=4]/pricing-table-grid:@4xl/pricing-table:py-10",
          ],
          className
        )}
        data-highlight={highlight}
        {...props}
      >
        {children}
      </Card>
    </PricingTableCardContext.Provider>
  )
}

export function PricingTableCardHeader({
  className,
  ...props
}: React.ComponentProps<typeof CardHeader>) {
  return (
    <CardHeader className={cn("grid-rows-[auto_1fr]", className)} {...props} />
  )
}

export function PricingTableCardBadge({
  children,
  className,
  ...props
}: React.ComponentProps<typeof Badge>) {
  const { highlight } = usePricingTableCardContext()
  if (!highlight) return null
  return (
    <Badge
      className={cn(
        "absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2",
        className
      )}
      {...props}
    >
      {children}
    </Badge>
  )
}

export function PricingTableCardTitle({
  className,
  ...props
}: React.ComponentProps<typeof CardTitle>) {
  return <CardTitle className={cn("text-lg", className)} {...props} />
}

export function PricingTableCardDescription({
  className,
  ...props
}: React.ComponentProps<typeof CardDescription>) {
  return (
    <CardDescription
      className={cn(
        "text-muted-foreground col-span-full row-start-2 line-clamp-2 text-sm",
        className
      )}
      {...props}
    />
  )
}

export function PricingTableCardSlot(
  props: React.ComponentProps<typeof CardAction>
) {
  return <CardAction {...props} data-slot="card-action testing" />
}

export function PricingTableCardPrice({
  monthly,
  yearly,
  className,
  suffix,
  ...props
}: Omit<
  React.ComponentProps<typeof NumberFlow>,
  "value" | "format" | "suffix"
> & {
  monthly: number
  yearly?: number
  suffix?: string | ((billingPeriod: BillingPeriod) => string)
}) {
  const { billingPeriod, priceFormatterOptions, locale } =
    usePricingTableContext()

  if (!monthly) {
    return null
  }

  const currentPrice = billingPeriod === "monthly" ? monthly : yearly || monthly

  return (
    <NumberFlow
      className={cn(
        "[&::part(suffix)]:text-muted-foreground text-lg font-semibold",
        className
      )}
      locales={locale}
      format={priceFormatterOptions}
      value={currentPrice}
      suffix={typeof suffix === "function" ? suffix(billingPeriod) : suffix}
      {...props}
    />
  )
}

export function PricingTableCardContent({
  className,
  ...props
}: React.ComponentProps<typeof CardContent>) {
  return (
    <CardContent
      className={cn("row-start-2 flex flex-col gap-2", className)}
      {...props}
    />
  )
}

export function PricingTableCardList(
  props: React.ComponentProps<typeof InfoList>
) {
  return <InfoList {...props} />
}

export function PricingTableCardListItem({
  className,
  ...props
}: React.ComponentProps<typeof InfoListItem>) {
  return <InfoListItem className={cn("gap-2 text-sm", className)} {...props} />
}

export function PricingTableCardListIcon({
  className,
  ...props
}: React.ComponentProps<typeof InfoListIcon>) {
  return (
    <InfoListIcon
      className={cn("text-muted-foreground", className)}
      {...props}
    />
  )
}

export function PricingTableCardListText(
  props: React.ComponentProps<typeof InfoListText>
) {
  return <InfoListText {...props} />
}

export function PricingTableCardFooter({
  className,
  ...props
}: React.ComponentProps<typeof CardFooter>) {
  return (
    <CardFooter
      className={cn("row-start-3 [&>[data-slot=button]]:w-full", className)}
      {...props}
    />
  )
}

export function PricingTableCardButton({
  className,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { highlight } = usePricingTableCardContext()
  return (
    <Button
      className={cn("w-full", className)}
      variant={highlight ? "default" : "outline"}
      {...props}
    />
  )
}

Installation

npx shadcn@latest add @alexcarpenter/pricing-table

Usage

import { PricingTable } from "@/components/ui/pricing-table"
<PricingTable />