Editorial

Pricing

Pricing card grid on the stock Card, using subgrid rows so titles, prices, descriptions and features align across plans. Gapped cards or a cells plate with hairline gaps; featured is a styling hook.

Free, MIT licenseOpen in Browse
import type { ComponentProps } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Grid, type GridProps } from "@/components/editorial/grid"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"

// cells is the plate: hairline gaps, corners outside; PricingCellCard sits in it.
const pricingVariants = cva("group/pricing", {
  variants: {
    /**
     * How the cards meet. `default` (the default) spaces them 16px apart.
     * `cells` joins them into one panel with `rounded-lg` corners and a 1px
     * ring, split by 1px lines, both in `border`; use it with
     * `PricingCellCard`.
     */
    variant: {
      default: "",
      cells: "overflow-hidden rounded-lg bg-border ring-1 ring-border",
    },
  },
  defaultVariants: { variant: "default" },
})

const pricingRows = [
  "has-[>[data-slot=pricing-card]>[data-slot=pricing-card-header]>:nth-child(1_of_:not([data-slot=pricing-card-action]))]:[--pricing-h1:1]",
  "has-[>[data-slot=pricing-card]>[data-slot=pricing-card-header]>:nth-child(2_of_:not([data-slot=pricing-card-action]))]:[--pricing-h2:1]",
  "has-[>[data-slot=pricing-card]>[data-slot=pricing-card-header]>:nth-child(3_of_:not([data-slot=pricing-card-action]))]:[--pricing-h3:1]",
  "has-[>[data-slot=pricing-card]>[data-slot=pricing-card-header]>:nth-child(4_of_:not([data-slot=pricing-card-action]))]:[--pricing-h4:1]",
  "has-[>[data-slot=pricing-card]>[data-slot=pricing-card-header]>:nth-child(5_of_:not([data-slot=pricing-card-action]))]:[--pricing-h5:1]",
  "has-[>[data-slot=pricing-card]>:nth-child(1_of_:not([data-slot=pricing-card-header]))]:[--pricing-p1:1]",
  "has-[>[data-slot=pricing-card]>:nth-child(2_of_:not([data-slot=pricing-card-header]))]:[--pricing-p2:1]",
  "has-[>[data-slot=pricing-card]>:nth-child(3_of_:not([data-slot=pricing-card-header]))]:[--pricing-p3:1]",
  "[--pricing-header-rows:calc(var(--pricing-h1,0)+var(--pricing-h2,0)+var(--pricing-h3,0)+var(--pricing-h4,0)+var(--pricing-h5,0))]",
  "[--pricing-card-rows:calc(var(--pricing-header-rows)+var(--pricing-p1,0)+var(--pricing-p2,0)+var(--pricing-p3,0))]",
]

/**
 * The grid of plan cards, built on `Grid`. Its `PricingCard` or
 * `PricingCellCard` children share rows, so each card's header parts and
 * sections line up with the cards beside it, even when their copy runs to
 * different lengths. `cols` (3 by default) is the most columns it shows; it
 * steps up to them as the space it is given widens.
 *
 * Keep the cards as direct children, or the shared rows cannot count them.
 * The rows count up to five parts in a `PricingCardHeader`, not counting
 * `PricingCardAction`, and up to three parts after the header. The `cells`
 * variant joins the cards into one rounded panel split by 1px lines; use it
 * with `PricingCellCard`. `className` lands on the grid.
 */
function Pricing({
  className,
  cols = 3,
  variant = "default",
  ...props
}: ComponentProps<"div"> &
  VariantProps<typeof pricingVariants> & {
    /**
     * The most columns of plans, 3 by default. They step up as the space
     * widens, the way `Grid`'s do.
     */
    cols?: GridProps["cols"]
  }) {
  return (
    <Grid
      data-slot="pricing"
      data-variant={variant}
      cols={cols}
      gap={variant === "cells" ? "px" : "sm"}
      className={cn(pricingVariants({ variant }), pricingRows, className)}
      {...props}
    />
  )
}

/**
 * A vertical stack with a 32px gap, for setting a control such as
 * `PricingToggle` above `Pricing`.
 */
function PricingGroup({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="pricing-group"
      className={cn("flex flex-col gap-8", className)}
      {...props}
    />
  )
}

/**
 * One plan, built on the stock `Card`. Put a `PricingCardHeader` inside it,
 * then `PricingCardContent` and `PricingCardFooter`. Inside `Pricing` it
 * takes its rows from the grid, so its parts line up with the cards beside
 * it. Its inner spacing grows from 24px to 32px once the viewport is 42rem
 * wide.
 */
function PricingCard({
  className,
  featured,
  ...props
}: Omit<ComponentProps<typeof Card>, "size"> & {
  /**
   * The stock `Card` size. `sm` sets the inner spacing to 16px at every
   * width, in place of 24px growing to 32px. The 24px gap between parts
   * stays.
   */
  size?: ComponentProps<typeof Card>["size"]
  /** Sets `data-featured` for your own styles. It adds no styling itself. */
  featured?: boolean
}) {
  return (
    <Card
      data-slot="pricing-card"
      data-featured={featured ? "" : undefined}
      className={cn(
        "gap-6 in-data-[slot=pricing]:row-span-(--pricing-card-rows) in-data-[slot=pricing]:grid in-data-[slot=pricing]:grid-rows-subgrid min-[42rem]:[--card-spacing:--spacing(8)]",
        className
      )}
      {...props}
    />
  )
}

/**
 * A `PricingCard` for the `cells` variant of `Pricing`. It drops the card's
 * rounding, shadow and ring so the cards meet as cells of one panel, and
 * keeps 32px inner spacing at every width.
 */
function PricingCellCard({
  className,
  ...props
}: Omit<ComponentProps<typeof PricingCard>, "size"> & {
  /**
   * The stock `Card` size. `sm` sets the inner spacing to 16px in place of
   * 32px. The 24px gap between parts stays.
   */
  size?: ComponentProps<typeof PricingCard>["size"]
}) {
  return (
    <PricingCard
      className={cn(
        "rounded-none shadow-none ring-0 [--card-spacing:--spacing(8)]",
        className
      )}
      {...props}
    />
  )
}

/**
 * The top of a plan card. Holds `PricingCardTitle`, an optional
 * `PricingCardAction`, `PricingCardPrice` and `PricingCardDescription`, in a
 * grid with 16px gaps. A `PricingCardAction` gets a column of its own on the
 * right.
 *
 * Inside `Pricing` each part takes one shared row, so keep the parts in the
 * same order on every card.
 */
function PricingCardHeader({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="pricing-card-header"
      className={cn(
        "group/pricing-card-header grid auto-rows-min items-start gap-4 px-(--card-spacing) in-data-[slot=pricing]:row-span-(--pricing-header-rows) in-data-[slot=pricing]:grid-rows-subgrid has-data-[slot=pricing-card-action]:grid-cols-[1fr_auto]",
        className
      )}
      {...props}
    />
  )
}

/**
 * The plan's name, an `h3` by default. Pass `render` to use another heading
 * level.
 */
function PricingCardTitle({
  className,
  render,
  ...props
}: useRender.ComponentProps<"h3">) {
  return useRender({
    defaultTagName: "h3",
    render,
    state: { slot: "pricing-card-title" },
    props: mergeProps<"h3">(
      { className: cn("text-xl/7 font-medium", className) },
      props
    ),
  })
}

/**
 * A slot at the top right of `PricingCardHeader`, beside the title, for a
 * `PricingCardBadge`. If the header starts with something other than
 * `PricingCardTitle`, it moves down to the second row.
 */
function PricingCardAction({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="pricing-card-action"
      className={cn(
        "col-start-2 row-start-1 self-start justify-self-end group-has-[>:first-child:not([data-slot=pricing-card-title],[data-slot=pricing-card-action])]/pricing-card-header:row-start-2",
        className
      )}
      {...props}
    />
  )
}

/** The stock `Badge`, for a plan label, usually inside `PricingCardAction`. */
function PricingCardBadge(
  props: Omit<ComponentProps<typeof Badge>, "variant"> & {
    /**
     * The stock `Badge` variant: `default` (the default) a primary fill,
     * `secondary` the secondary fill, `outline` a border only, `destructive`
     * a tint of the destructive color, `ghost` no fill until hover, and
     * `link` primary text underlined on hover.
     */
    variant?: ComponentProps<typeof Badge>["variant"]
  }
) {
  return <Badge {...props} />
}

/**
 * The price line, a paragraph that sets `PricingCardAmount` and
 * `PricingCardPeriod` on one baseline. When the amount is too long to share
 * the line, the period wraps under it.
 */
function PricingCardPrice({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="pricing-card-price"
      className={cn(
        "col-span-full flex flex-wrap items-baseline gap-x-2",
        className
      )}
      {...props}
    />
  )
}

/** The price figure in large type, inside `PricingCardPrice`. */
function PricingCardAmount({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="pricing-card-amount"
      className={cn("text-4xl font-normal tracking-tight", className)}
      {...props}
    />
  )
}

/** The billing period beside `PricingCardAmount`, in smaller muted type. */
function PricingCardPeriod({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="pricing-card-period"
      className={cn("text-base/7 font-medium text-muted-foreground", className)}
      {...props}
    />
  )
}

/** A short muted paragraph about the plan, across the full header width. */
function PricingCardDescription({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="pricing-card-description"
      className={cn(
        "col-span-full text-base/7 text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

/**
 * The middle of a plan card, under the header. Holds `PricingCardFeatures`
 * and any other body content, stacked with 24px gaps.
 */
function PricingCardContent({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="pricing-card-content"
      className={cn(
        "grid flex-1 content-start gap-6 px-(--card-spacing)",
        className
      )}
      {...props}
    />
  )
}

/** The list of what a plan includes. Holds `PricingCardFeature` items. */
function PricingCardFeatures({ className, ...props }: ComponentProps<"ul">) {
  return (
    <ul
      data-slot="pricing-card-features"
      className={cn("grid gap-4 text-sm/6", className)}
      {...props}
    />
  )
}

/**
 * One line in `PricingCardFeatures`, an `li` by default. Put an icon first:
 * an `svg` child is sized 20px wide and 24px tall, never shrinks, and sits
 * 12px from the text.
 */
function PricingCardFeature({
  className,
  render,
  ...props
}: useRender.ComponentProps<"li">) {
  return useRender({
    defaultTagName: "li",
    render,
    state: { slot: "pricing-card-feature" },
    props: mergeProps<"li">(
      {
        className: cn(
          "flex gap-x-3 *:[svg]:h-6 *:[svg]:w-5 *:[svg]:flex-none",
          className
        ),
      },
      props
    ),
  })
}

/**
 * The bottom of a plan card, for its call to action. It is a column, so a
 * single button stretches to the card's width. Add `flex-row` to set several
 * actions side by side.
 */
function PricingCardFooter({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="pricing-card-footer"
      className={cn("flex flex-col px-(--card-spacing)", className)}
      {...props}
    />
  )
}

export {
  Pricing,
  PricingGroup,
  PricingCard,
  PricingCellCard,
  PricingCardHeader,
  PricingCardTitle,
  PricingCardAction,
  PricingCardBadge,
  PricingCardPrice,
  PricingCardAmount,
  PricingCardPeriod,
  PricingCardDescription,
  PricingCardContent,
  PricingCardFeatures,
  PricingCardFeature,
  PricingCardFooter,
}

Installation

pnpm dlx shadcn add @01orbital/pricing
npx shadcn add @01orbital/pricing
yarn shadcn add @01orbital/pricing
bunx --bun shadcn add @01orbital/pricing

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/editorial/pricing.tsx, as source you own and edit.

The example also uses button and section. Install them too:

pnpm dlx shadcn add @01orbital/button @01orbital/section
npx shadcn add @01orbital/button @01orbital/section
yarn shadcn add @01orbital/button @01orbital/section
bunx --bun shadcn add @01orbital/button @01orbital/section

The example also imports @phosphor-icons/react from npm, which the install does not add:

pnpm add @phosphor-icons/react
npm install @phosphor-icons/react
yarn add @phosphor-icons/react
bun add @phosphor-icons/react

Usage

import { CheckIcon } from "@phosphor-icons/react/dist/ssr"

import {
  Pricing,
  PricingCard,
  PricingCardAmount,
  PricingCardContent,
  PricingCardDescription,
  PricingCardFeature,
  PricingCardFeatures,
  PricingCardFooter,
  PricingCardHeader,
  PricingCardPeriod,
  PricingCardPrice,
  PricingCardTitle,
} from "@/components/editorial/pricing"
import {
  Section,
  SectionContent,
} from "@/components/editorial/section"
import { Button } from "@/components/ui/button"

const COPY = {
  period: "/ month",
  cta: "Get started",
  plans: [
    {
      name: "Starter",
      price: "$12",
      description: "Side projects and first deploys.",
      features: ["Up to 2 projects", "Preview deploys", "Email support"],
      featured: false,
    },
    {
      name: "Growth",
      price: "$49",
      description: "Teams pushing to production every day.",
      features: ["Unlimited projects", "Concurrent builds", "Priority support"],
      featured: true,
    },
  ],
} as const

export default function PricingDemo() {
  return (
    <Section className="py-0">
      <SectionContent className="px-0 @5xl/section:px-0">
        <Pricing cols={2}>
          {COPY.plans.map((plan) => (
            <PricingCard key={plan.name} featured={plan.featured}>
              <PricingCardHeader>
                <PricingCardTitle>{plan.name}</PricingCardTitle>
                <PricingCardPrice>
                  <PricingCardAmount>{plan.price}</PricingCardAmount>
                  <PricingCardPeriod>{COPY.period}</PricingCardPeriod>
                </PricingCardPrice>
                <PricingCardDescription>
                  {plan.description}
                </PricingCardDescription>
              </PricingCardHeader>
              <PricingCardContent>
                <PricingCardFeatures>
                  {plan.features.map((feature) => (
                    <PricingCardFeature key={feature}>
                      <CheckIcon weight="bold" aria-hidden="true" />
                      {feature}
                    </PricingCardFeature>
                  ))}
                </PricingCardFeatures>
              </PricingCardContent>
              <PricingCardFooter>
                <Button variant={plan.featured ? "default" : "outline"}>
                  {COPY.cta}
                </Button>
              </PricingCardFooter>
            </PricingCard>
          ))}
        </Pricing>
      </SectionContent>
    </Section>
  )
}

Anatomy

Pricing is built from 16 parts. In the example they nest like this:

Pricing
└─ PricingCard
   ├─ PricingCardHeader
   │  ├─ PricingCardTitle
   │  ├─ PricingCardPrice
   │  │  ├─ PricingCardAmount
   │  │  └─ PricingCardPeriod
   │  └─ PricingCardDescription
   ├─ PricingCardContent
   │  └─ PricingCardFeatures
   │     └─ PricingCardFeature
   └─ PricingCardFooter
  • Pricing: The grid of plan cards, built on Grid. Its PricingCard or PricingCellCard children share rows, so each card's header parts and sections line up with the cards beside it, even when their copy runs to different lengths. cols (3 by default) is the most columns it shows; it steps up to them as the space it is given widens.
  • PricingCard: One plan, built on the stock Card. Put a PricingCardHeader inside it, then PricingCardContent and PricingCardFooter. Inside Pricing it takes its rows from the grid, so its parts line up with the cards beside it. Its inner spacing grows from 24px to 32px once the viewport is 42rem wide.
  • PricingCardHeader: The top of a plan card. Holds PricingCardTitle, an optional PricingCardAction, PricingCardPrice and PricingCardDescription, in a grid with 16px gaps. A PricingCardAction gets a column of its own on the right.
  • PricingCardTitle: The plan's name, an h3 by default. Pass render to use another heading level.
  • PricingCardPrice: The price line, a paragraph that sets PricingCardAmount and PricingCardPeriod on one baseline. When the amount is too long to share the line, the period wraps under it.
  • PricingCardAmount: The price figure in large type, inside PricingCardPrice.
  • PricingCardPeriod: The billing period beside PricingCardAmount, in smaller muted type.
  • PricingCardDescription: A short muted paragraph about the plan, across the full header width.
  • PricingCardContent: The middle of a plan card, under the header. Holds PricingCardFeatures and any other body content, stacked with 24px gaps.
  • PricingCardFeatures: The list of what a plan includes. Holds PricingCardFeature items.
  • PricingCardFeature: One line in PricingCardFeatures, an li by default. Put an icon first: an svg child is sized 20px wide and 24px tall, never shrinks, and sits 12px from the text.
  • PricingCardFooter: The bottom of a plan card, for its call to action. It is a column, so a single button stretches to the card's width. Add flex-row to set several actions side by side.
  • PricingGroup: A vertical stack with a 32px gap, for setting a control such as PricingToggle above Pricing.
  • PricingCellCard: A PricingCard for the cells variant of Pricing. It drops the card's rounding, shadow and ring so the cards meet as cells of one panel, and keeps 32px inner spacing at every width.
  • PricingCardAction: A slot at the top right of PricingCardHeader, beside the title, for a PricingCardBadge. If the header starts with something other than PricingCardTitle, it moves down to the second row.
  • PricingCardBadge: The stock Badge, for a plan label, usually inside PricingCardAction.

API reference

Pricing

The grid of plan cards, built on Grid. Its PricingCard or PricingCellCard children share rows, so each card's header parts and sections line up with the cards beside it, even when their copy runs to different lengths. cols (3 by default) is the most columns it shows; it steps up to them as the space it is given widens.

Keep the cards as direct children, or the shared rows cannot count them. The rows count up to five parts in a PricingCardHeader, not counting PricingCardAction, and up to three parts after the header. The cells variant joins the cards into one rounded panel split by 1px lines; use it with PricingCellCard. className lands on the grid.

Prop

Type

It also accepts the HTML attributes of the element it renders.

PricingCard

One plan, built on the stock Card. Put a PricingCardHeader inside it, then PricingCardContent and PricingCardFooter. Inside Pricing it takes its rows from the grid, so its parts line up with the cards beside it. Its inner spacing grows from 24px to 32px once the viewport is 42rem wide.

Prop

Type

It also accepts the HTML attributes of the element it renders.

PricingCardHeader

The top of a plan card. Holds PricingCardTitle, an optional PricingCardAction, PricingCardPrice and PricingCardDescription, in a grid with 16px gaps. A PricingCardAction gets a column of its own on the right.

Inside Pricing each part takes one shared row, so keep the parts in the same order on every card.

It accepts the HTML attributes of the element it renders.

PricingCardTitle

The plan's name, an h3 by default. Pass render to use another heading level.

It accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

PricingCardPrice

The price line, a paragraph that sets PricingCardAmount and PricingCardPeriod on one baseline. When the amount is too long to share the line, the period wraps under it.

It accepts the HTML attributes of the element it renders.

PricingCardAmount

The price figure in large type, inside PricingCardPrice.

It accepts the HTML attributes of the element it renders.

PricingCardPeriod

The billing period beside PricingCardAmount, in smaller muted type.

It accepts the HTML attributes of the element it renders.

PricingCardDescription

A short muted paragraph about the plan, across the full header width.

It accepts the HTML attributes of the element it renders.

PricingCardContent

The middle of a plan card, under the header. Holds PricingCardFeatures and any other body content, stacked with 24px gaps.

It accepts the HTML attributes of the element it renders.

PricingCardFeatures

The list of what a plan includes. Holds PricingCardFeature items.

It accepts the HTML attributes of the element it renders.

PricingCardFeature

One line in PricingCardFeatures, an li by default. Put an icon first: an svg child is sized 20px wide and 24px tall, never shrinks, and sits 12px from the text.

It accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

The bottom of a plan card, for its call to action. It is a column, so a single button stretches to the card's width. Add flex-row to set several actions side by side.

It accepts the HTML attributes of the element it renders.

PricingGroup

A vertical stack with a 32px gap, for setting a control such as PricingToggle above Pricing.

It accepts the HTML attributes of the element it renders.

PricingCellCard

A PricingCard for the cells variant of Pricing. It drops the card's rounding, shadow and ring so the cards meet as cells of one panel, and keeps 32px inner spacing at every width.

Prop

Type

It also accepts the HTML attributes of the element it renders.

PricingCardAction

A slot at the top right of PricingCardHeader, beside the title, for a PricingCardBadge. If the header starts with something other than PricingCardTitle, it moves down to the second row.

It accepts the HTML attributes of the element it renders.

PricingCardBadge

The stock Badge, for a plan label, usually inside PricingCardAction.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

Dependencies

npm: @base-ui/react, class-variance-authority, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • badge
  • card
  • grid