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.
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,
}
pnpm dlx shadcn add @01orbital/pricingnpx shadcn add @01orbital/pricingyarn shadcn add @01orbital/pricingbunx --bun shadcn add @01orbital/pricingFree 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/sectionnpx shadcn add @01orbital/button @01orbital/sectionyarn shadcn add @01orbital/button @01orbital/sectionbunx --bun shadcn add @01orbital/button @01orbital/sectionThe example also imports @phosphor-icons/react from npm, which the install does not add:
pnpm add @phosphor-icons/reactnpm install @phosphor-icons/reactyarn add @phosphor-icons/reactbun add @phosphor-icons/reactimport { 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>
)
}Pricing is built from 16 parts. In the example they nest like this:
Pricing
└─ PricingCard
├─ PricingCardHeader
│ ├─ PricingCardTitle
│ ├─ PricingCardPrice
│ │ ├─ PricingCardAmount
│ │ └─ PricingCardPeriod
│ └─ PricingCardDescription
├─ PricingCardContent
│ └─ PricingCardFeatures
│ └─ PricingCardFeature
└─ PricingCardFooterPricing: 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.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.
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.
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.
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.
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.
The price figure in large type, inside PricingCardPrice.
It accepts the HTML attributes of the element it renders.
The billing period beside PricingCardAmount, in smaller muted type.
It accepts the HTML attributes of the element it renders.
A short muted paragraph about the plan, across the full header width.
It accepts the HTML attributes of the element it renders.
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.
The list of what a plan includes. Holds PricingCardFeature items.
It accepts the HTML attributes of the element it renders.
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.
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.
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.
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.
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.
npm: @base-ui/react, class-variance-authority, cn. The CLI installs them for you.
Registry pieces it installs with it:
badgecardgridPost
Blog post listing parts: a Grid list or divided column of posts, each made fully clickable by its title link, with cover media, dotted meta for date, category and author, title and clamped excerpt.
Pricing Toggle
Billing period switch, such as monthly and yearly, built on the stock Tabs list. A controlled client component that takes a typed value and a change handler.