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
npx shadcn@latest add https://satisui.xyz/r/pricing-card.jsonManual
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. |