
Three-tier card row with a monthly/yearly billing toggle that tweens the price, a featured plan, per-plan icon, feature checklist and CTA.
@hirael
registry/hirael/bases/radix/blocks/pricing-03/pricing-03.tsx
// Pricing 03 from Hirael <https://hirael.com/blocks/pricing/pricing-03>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael
'use client';
import * as React from 'react';
import { ArrowRight, Check, Shield, Star, Zap } from 'lucide-react';
import { AnimatedNumber } from '@/registry/hirael/bases/radix/components/animated-number';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/registry/hirael/bases/radix/ui/card';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';
import { cn } from '@/lib/utils';
const ENTER =
'animate-in fade-in slide-in-from-bottom-4 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
'animate-in fade-in slide-in-from-bottom-1 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const stagger = (index: number, step = 60, offset = 0): React.CSSProperties => ({
animationDelay: `${offset + index * step}ms`,
});
type Frequency = 'monthly' | 'yearly';
interface Plan {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
price: Record<Frequency, number | string>;
description: string;
features: readonly string[];
cta: string;
ctaVariant: 'default' | 'outline';
popular?: boolean;
}
const PLANS: readonly Plan[] = [
{
id: 'starter',
name: 'Starter',
icon: Star,
price: { monthly: 'Free', yearly: 'Free' },
description: 'Everything you need to ship your first project.',
features: ['1,000 generations a month', 'Base model access', 'Community support', '500 MB model storage'],
cta: 'Start free',
ctaVariant: 'outline',
},
{
id: 'pro',
name: 'Pro',
icon: Zap,
price: { monthly: 90, yearly: 75 },
description: 'For teams shipping to real users.',
features: ['Unlimited generations', 'Premium model access', 'Priority support', '10 GB model storage'],
cta: 'Choose Pro',
ctaVariant: 'default',
popular: true,
},
{
id: 'enterprise',
name: 'Enterprise',
icon: Shield,
price: { monthly: 'Custom', yearly: 'Custom' },
description: 'For companies that need their own models and a signed contract.',
features: ['Custom models', 'Dedicated account manager', 'On-premise deployment', 'Unlimited secure storage'],
cta: 'Contact sales',
ctaVariant: 'outline',
},
];
const PRICE_FORMAT: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 };
const money = new Intl.NumberFormat('en-US', PRICE_FORMAT);
const yearlySaving = Math.max(
0,
...PLANS.map(({ price }) =>
typeof price.monthly === 'number' && typeof price.yearly === 'number'
? Math.round((1 - price.yearly / price.monthly) * 100)
: 0,
),
);
const Pricing03 = () => {
const [frequency, setFrequency] = React.useState<Frequency>('monthly');
return (
<section data-slot="pricing" className="relative overflow-hidden bg-background py-20 text-foreground sm:py-28">
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="absolute start-1/2 -top-24 h-72 w-3/5 -translate-x-1/2 rounded-full bg-foreground/[0.06] blur-3xl rtl:translate-x-1/2" />
<div className="absolute end-[-8%] -bottom-24 h-72 w-2/5 rounded-full bg-primary/10 blur-3xl" />
</div>
<div className="relative mx-auto flex max-w-[1480px] flex-col items-center gap-8 px-4">
<div data-slot="pricing-header" className="flex max-w-2xl flex-col items-center gap-4 text-center">
<span className={cn(ENTER, 'text-xs text-muted-foreground uppercase')}>Pricing</span>
<h2
style={stagger(1)}
className={cn(ENTER, 'font-serif text-4xl leading-[1.04] font-medium tracking-tight sm:text-5xl')}
>
Pick the plan that fits your pace.
</h2>
<p style={stagger(2)} className={cn(ENTER, 'text-base text-muted-foreground sm:text-lg')}>
Start free, move to Pro when you need more generations, and change plans or billing whenever you like.
</p>
</div>
<ToggleGroup
data-slot="pricing-billing"
type="single"
variant="outline"
value={frequency}
onValueChange={(next) => next && setFrequency(next as Frequency)}
aria-label="Billing period"
style={stagger(3)}
className={ENTER}
>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
<ToggleGroupItem value="yearly">
Yearly
{yearlySaving > 0 && <Badge variant="secondary">Save {yearlySaving}%</Badge>}
</ToggleGroupItem>
</ToggleGroup>
<div data-slot="pricing-tiers" className="mt-4 grid w-full max-w-6xl grid-cols-1 gap-4 md:grid-cols-3">
{PLANS.map((plan, index) => {
const amount = plan.price[frequency];
const Icon = plan.icon;
return (
<Card
key={plan.id}
data-slot="pricing-tier"
data-featured={plan.popular ? '' : undefined}
style={{
...stagger(index, 60, 240),
...(plan.popular
? {
backgroundImage:
'radial-gradient(120% 90% at 50% 0%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 60%)',
}
: undefined),
}}
className={cn(ENTER, 'relative h-full text-start')}
>
{plan.popular && (
<span className="absolute end-6 -top-2.5 inline-flex items-center rounded-full border border-border bg-background px-2.5 py-0.5 text-xs text-foreground uppercase">
Most popular
</span>
)}
<CardHeader>
<div className="flex items-center gap-2">
<Icon aria-hidden className="size-4 text-muted-foreground" />
<CardTitle>{plan.name}</CardTitle>
</div>
<CardDescription>{plan.description}</CardDescription>
<div data-slot="pricing-price" className="flex min-h-14 flex-col justify-end gap-1 pt-1">
{typeof amount === 'number' ? (
<>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-semibold tracking-[-0.04em] text-foreground">
<AnimatedNumber dir="ltr" format={PRICE_FORMAT} duration={250} value={amount} />
</span>
<span className="text-xs text-muted-foreground">/ month</span>
</div>
<span key={frequency} className={cn(SWAP, 'text-xs text-muted-foreground')}>
{frequency === 'yearly' ? (
<>
<span dir="ltr" className="tabular-nums">
{money.format(amount * 12)}
</span>{' '}
billed once a year
</>
) : (
'Billed monthly, cancel any time'
)}
</span>
</>
) : (
<span className="text-3xl font-semibold tracking-[-0.03em] text-foreground">{amount}</span>
)}
</div>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-2.5">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5 text-sm text-foreground">
<Check aria-hidden className="mt-0.5 size-4 shrink-0" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter className="mt-auto">
<Button asChild variant={plan.ctaVariant} size="lg" className="group w-full">
<a href="#">
{plan.cta}
<ArrowRight className="size-4 transition-transform duration-150 group-hover:translate-x-0.5 rtl:rotate-180 rtl:group-hover:-translate-x-0.5" />
</a>
</Button>
</CardFooter>
</Card>
);
})}
</div>
</div>
</section>
);
};
export default Pricing03;



























