
In-app plan selector with featured and current states, a badge, feature checklist and per-plan action. Ships composable parts.
@hirael
registry/hirael/bases/radix/blocks/subscription-plans/subscription-plans.tsx
// Subscription Plans from Hirael <https://hirael.com/blocks/saas/subscription-plans>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael
'use client';
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/registry/hirael/bases/radix/ui/alert-dialog';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Spinner } from '@/registry/hirael/bases/radix/ui/spinner';
type SubscriptionPlansProps = React.ComponentProps<'div'>;
const SubscriptionPlans = ({ className, ...props }: SubscriptionPlansProps) => {
return <div data-slot="subscription-plans" className={cn('grid gap-3 sm:grid-cols-3', className)} {...props} />;
};
interface SubscriptionPlanProps extends React.ComponentProps<'div'> {
featured?: boolean;
current?: boolean;
}
const SubscriptionPlan = ({ featured, current, className, ...props }: SubscriptionPlanProps) => {
return (
<div
data-slot="subscription-plan"
data-featured={featured ? '' : undefined}
data-current={current ? '' : undefined}
className={cn(
'relative flex flex-col gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground',
featured && 'border-foreground/30 ring-1 ring-foreground/20',
className,
)}
{...props}
/>
);
};
type SubscriptionPlanBadgeProps = React.ComponentProps<'span'>;
const SubscriptionPlanBadge = ({ className, ...props }: SubscriptionPlanBadgeProps) => {
return (
<Badge
variant="secondary"
data-slot="subscription-plan-badge"
className={cn('absolute end-4 -top-2.5', className)}
{...props}
/>
);
};
type SubscriptionPlanNameProps = React.ComponentProps<'h3'>;
const SubscriptionPlanName = ({ className, ...props }: SubscriptionPlanNameProps) => {
return (
<h3
data-slot="subscription-plan-name"
className={cn('text-sm font-medium text-foreground', className)}
{...props}
/>
);
};
interface SubscriptionPlanPriceProps extends React.ComponentProps<'p'> {
cycle?: React.ReactNode;
}
const SubscriptionPlanPrice = ({ cycle, className, children, ...props }: SubscriptionPlanPriceProps) => {
return (
<p data-slot="subscription-plan-price" className={cn('flex items-baseline gap-1', className)} {...props}>
<span className="text-3xl font-semibold tracking-tight text-foreground">{children}</span>
{cycle ? <span className="text-sm text-muted-foreground">/ {cycle}</span> : null}
</p>
);
};
type SubscriptionPlanDescriptionProps = React.ComponentProps<'p'>;
const SubscriptionPlanDescription = ({ className, ...props }: SubscriptionPlanDescriptionProps) => {
return (
<p
data-slot="subscription-plan-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
};
type SubscriptionPlanFeaturesProps = React.ComponentProps<'ul'>;
const SubscriptionPlanFeatures = ({ className, ...props }: SubscriptionPlanFeaturesProps) => {
return (
<ul
data-slot="subscription-plan-features"
className={cn('flex flex-1 flex-col gap-2 text-sm', className)}
{...props}
/>
);
};
type SubscriptionPlanFeatureProps = React.ComponentProps<'li'>;
const SubscriptionPlanFeature = ({ className, children, ...props }: SubscriptionPlanFeatureProps) => {
return (
<li
data-slot="subscription-plan-feature"
className={cn('flex items-center gap-2 text-muted-foreground', className)}
{...props}
>
<Check className="size-4 shrink-0 text-foreground" aria-hidden />
<span>{children}</span>
</li>
);
};
interface SubscriptionPlanActionProps extends React.ComponentProps<'button'> {
variant?: 'primary' | 'outline';
}
const SubscriptionPlanAction = ({ variant = 'outline', className, ...props }: SubscriptionPlanActionProps) => {
return (
<Button
type="button"
variant={variant === 'primary' ? 'default' : 'outline'}
data-slot="subscription-plan-action"
className={cn('w-full', className)}
{...props}
/>
);
};
export {
SubscriptionPlans,
SubscriptionPlan,
SubscriptionPlanBadge,
SubscriptionPlanName,
SubscriptionPlanPrice,
SubscriptionPlanDescription,
SubscriptionPlanFeatures,
SubscriptionPlanFeature,
SubscriptionPlanAction,
};
const ENTER =
'animate-in fade-in slide-in-from-bottom-2 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
'animate-in fade-in zoom-in-97 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const PLANS = [
{
id: 'starter',
name: 'Starter',
price: '$0',
description: 'For side projects and trials.',
features: ['1 project', 'Community support', '1k requests / day'],
},
{
id: 'pro',
name: 'Pro',
price: '$29',
description: 'For growing teams shipping every week.',
features: ['Unlimited projects', 'Priority support', '100k requests / day', 'Audit log'],
featured: true,
},
{
id: 'scale',
name: 'Scale',
price: '$99',
description: 'For high-volume production.',
features: ['Everything in Pro', 'SSO and SAML', 'Unlimited requests'],
},
];
const SubscriptionPlansBlock = () => {
const [currentId, setCurrentId] = React.useState('scale');
const [pendingId, setPendingId] = React.useState<string | null>(null);
const [downgradeId, setDowngradeId] = React.useState<string | null>(null);
const [downgradeOpen, setDowngradeOpen] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(() => () => clearTimeout(timer.current), []);
const currentIndex = PLANS.findIndex((plan) => plan.id === currentId);
const downgradePlan = PLANS.find((plan) => plan.id === downgradeId);
const confirmDowngrade = (id: string) => {
setDowngradeId(id);
setDowngradeOpen(true);
};
const choose = (id: string) => {
setPendingId(id);
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setCurrentId(id);
setPendingId(null);
}, 1000);
};
return (
<section data-slot="subscription-plans-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
<SubscriptionPlans className="w-full max-w-3xl">
{PLANS.map((plan, index) => {
const current = plan.id === currentId;
const pending = plan.id === pendingId;
const upgrade = index > currentIndex;
return (
<SubscriptionPlan
key={plan.id}
featured={plan.featured}
current={current}
style={{ animationDelay: `${index * 60}ms` }}
className={ENTER}
>
{current ? (
<SubscriptionPlanBadge key="current" className={SWAP}>
Current
</SubscriptionPlanBadge>
) : plan.featured ? (
<SubscriptionPlanBadge key="featured">Popular</SubscriptionPlanBadge>
) : null}
<SubscriptionPlanName>{plan.name}</SubscriptionPlanName>
<SubscriptionPlanPrice cycle="mo">{plan.price}</SubscriptionPlanPrice>
<SubscriptionPlanDescription>{plan.description}</SubscriptionPlanDescription>
<SubscriptionPlanFeatures>
{plan.features.map((feature) => (
<SubscriptionPlanFeature key={feature}>{feature}</SubscriptionPlanFeature>
))}
</SubscriptionPlanFeatures>
{current ? (
<SubscriptionPlanAction disabled>Current plan</SubscriptionPlanAction>
) : (
<SubscriptionPlanAction
variant={plan.featured ? 'primary' : 'outline'}
disabled={pendingId !== null}
aria-busy={pending || undefined}
onClick={() => (upgrade ? choose(plan.id) : confirmDowngrade(plan.id))}
>
{pending ? (
<>
<Spinner aria-hidden className="motion-reduce:animate-none" />
Switching
</>
) : (
`${upgrade ? 'Upgrade to' : 'Downgrade to'} ${plan.name}`
)}
</SubscriptionPlanAction>
)}
</SubscriptionPlan>
);
})}
</SubscriptionPlans>
<AlertDialog open={downgradeOpen} onOpenChange={setDowngradeOpen}>
<AlertDialogContent data-slot="subscription-plans-downgrade-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Downgrade to {downgradePlan?.name}?</AlertDialogTitle>
<AlertDialogDescription>
You keep {PLANS[currentIndex].name} features until the end of this billing period. After that, anything
beyond the {downgradePlan?.name} limits is paused.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep {PLANS[currentIndex].name}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (downgradeId) choose(downgradeId);
setDowngradeOpen(false);
}}
>
Downgrade
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p aria-live="polite" className="sr-only">
{pendingId ? 'Changing plan' : `Current plan: ${PLANS[currentIndex].name}`}
</p>
</section>
);
};
export default SubscriptionPlansBlock;
































