Components
Pricing Card
A responsive card component for displaying a single pricing tier. It adapts its display based on the selected billing interval.
Installation
CLI
Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.
Manual
No additional dependencies are required. Copy and paste the following code into your project.
'use client';
import * as React from 'react';
import Link from 'next/link';
import { CheckCircle2, Info, XCircle } from 'lucide-react';
import type { VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button, buttonVariants } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
type PricingInterval = 'month' | 'year';
interface PricingFeature {
text: string;
included: boolean;
tooltip?: string;
isNew?: boolean;
}
interface Price {
monthly: number;
yearly: number;
}
/**
* Props for the PricingCard component.
*/
interface PricingCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** The currently selected billing interval. */
interval: PricingInterval;
/** The name of the pricing plan. */
name: string;
/** A short description of the plan. */
description: string;
/** The price, either a structured object for monthly/yearly costs or 'Custom' for enterprise plans. */
price: Price | 'Custom';
/** An optional subtitle for the price, perfect for per-seat costs or other contextual info. */
priceSubtitle?: React.ReactNode;
/** A list of features included in the plan. */
features: PricingFeature[];
/** Configuration for the call-to-action button. */
cta: {
text: string;
href: string;
variant?: VariantProps<typeof buttonVariants>['variant'];
};
/** Optional text for a badge, like 'Most Popular'. */
badgeText?: string;
/** If true, the card will be visually highlighted as the featured plan. */
isFeatured?: boolean;
}
// NOTE: Memoizing the feature list item prevents re-rendering of the entire list
// when parent state changes, such as the billing interval.
const PricingFeature = React.memo(
({ feature }: { feature: PricingFeature }) => (
<li
className={cn(
'flex items-center gap-3 text-sm text-muted-foreground',
!feature.included && 'opacity-60'
)}
>
{feature.included ? (
<CheckCircle2 className='h-5 w-5 shrink-0 text-primary' />
) : (
<XCircle className='h-5 w-5 shrink-0' />
)}
<span
className={cn(
'flex-1',
feature.isNew && 'font-semibold text-foreground'
)}
>
{feature.text}
</span>
{feature.tooltip && (
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type='button'
className='cursor-default rounded-full'
aria-label='More info'
>
<Info className='h-4 w-4 text-muted-foreground' />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{feature.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</li>
)
);
PricingFeature.displayName = 'PricingFeature';
/**
* A responsive card component for displaying a single pricing tier.
* It adapts its display based on the selected billing interval.
*/
const PricingCard = React.forwardRef<HTMLDivElement, PricingCardProps>(
(
{
className,
interval,
name,
description,
price,
priceSubtitle,
features,
cta,
badgeText,
isFeatured = false,
...props
},
ref
) => {
const getPriceText = React.useCallback(() => {
if (price === 'Custom') return 'Custom';
const amount = interval === 'year' ? price.yearly / 12 : price.monthly;
return amount.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
// WHY: Allow decimal points for prices calculated from an annual total (e.g., $99/year -> $8.25/month).
maximumFractionDigits: 2,
});
}, [price, interval]);
const priceText = getPriceText();
return (
<Card
ref={ref}
className={cn(
'flex h-full flex-col transition-all duration-300 ease-in-out hover:scale-[1.02] hover:shadow-xl',
isFeatured
? 'relative ring-2 ring-primary shadow-2xl shadow-primary/10'
: 'border-border',
className
)}
{...props}
>
{badgeText && (
<Badge
className='absolute right-4 top-4'
variant={isFeatured ? 'default' : 'secondary'}
>
{badgeText}
</Badge>
)}
<CardHeader className='flex-shrink-0'>
<CardTitle>{name}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className='flex flex-1 flex-col'>
<div className='mb-6'>
<p className='flex items-baseline' aria-live='polite'>
<span
// WHY: The key is crucial here. It forces React to re-mount this
// element when the interval changes, which re-triggers the fade-in animation.
key={`${name}-${interval}-price`}
className='text-5xl font-bold tracking-tight text-foreground animate-in fade-in'
>
{priceText}
</span>
{price !== 'Custom' && (
<span className='ml-1.5 text-muted-foreground'>/month</span>
)}
</p>
{priceSubtitle && (
<p className='mt-1 text-sm text-muted-foreground animate-in fade-in'>
{priceSubtitle}
</p>
)}
{price !== 'Custom' && interval === 'year' && (
<p
key={`${name}-annual-billing`}
className='mt-1 text-sm text-muted-foreground animate-in fade-in'
>
Billed ${price.yearly.toLocaleString()} annually
</p>
)}
</div>
<hr className='mb-6 border-border/60' />
<ul className='space-y-4'>
{features.map((feature, i) => (
<PricingFeature key={i} feature={feature} />
))}
</ul>
</CardContent>
<CardFooter className='mt-auto flex-shrink-0 border-t pt-6'>
<Button
className='w-full'
variant={cta.variant ?? (isFeatured ? 'default' : 'outline')}
asChild
>
<Link href={cta.href}>{cta.text}</Link>
</Button>
</CardFooter>
</Card>
);
}
);
PricingCard.displayName = 'PricingCard';
export { PricingCard, type PricingCardProps, type PricingFeature, type Price };
Usage
Import the component and provide the necessary props to display pricing tiers.
import { PricingCard } from '@/components/ui/pricing-card';
export default function PricingPage() {
return (
<div className='grid grid-cols-1 md:grid-cols-2 gap-8'>
<PricingCard
interval='month'
name='Starter'
description='For individuals and hobby projects.'
price={{ monthly: 10, yearly: 100 }}
features={[
{ text: 'Basic Feature A', included: true },
{ text: 'Basic Feature B', included: true },
{ text: 'Advanced Feature C', included: false },
]}
cta={{
text: 'Get Started',
href: '#',
}}
/>
<PricingCard
interval='month'
name='Pro'
description='For growing businesses and professionals.'
price={{ monthly: 25, yearly: 250 }}
features={[
{ text: 'Basic Feature A', included: true },
{ text: 'Basic Feature B', included: true },
{ text: 'Advanced Feature C', included: true, isNew: true },
]}
cta={{
text: 'Upgrade to Pro',
href: '#',
}}
isFeatured={true}
badgeText='Popular'
/>
</div>
);
}
Props
Prop | Type | Default | Description |
---|---|---|---|
interval | 'month' | 'year' | Req | The currently selected billing interval. |
name | string | Req | The name of the pricing plan. |
description | string | Req | A short description of the plan. |
price | Price | 'Custom' | Req | The price, either a structured object for monthly/yearly costs or 'Custom' for enterprise plans. |
priceSubtitle | React.ReactNode | - | An optional subtitle for the price, perfect for per-seat costs or other contextual info. |
features | PricingFeature[] | Req | A list of features included in the plan. |
cta | { text: string; href: string; variant?: VariantProps<...>; } | Req | Configuration for the call-to-action button. |
badgeText | string | - | Optional text for a badge, like 'Most Popular'. |
isFeatured | boolean | false | If true, the card will be visually highlighted as the featured plan. |