Subscription Card

Subscription status and management card for plan, renewal, and usage details.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/subscription-card.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

import * as React from "react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "../card/card"; import { PlanBadge, type PlanBadgeTier } from "../plan-badge/plan-badge"; export type SubscriptionCardStatus = | "active" | "canceled" | "past-due" | "trialing"; export type SubscriptionCardProps = React.ComponentPropsWithoutRef< typeof Card > & { note?: string; plan: PlanBadgeTier; priceLabel: string; primaryActionLabel?: string; renewalLabel: string; seatsLabel?: string; secondaryActionLabel?: string; status: SubscriptionCardStatus; usageLabel?: string; }; type SubscriptionActionsProps = { primaryActionLabel?: string; secondaryActionLabel?: string; }; type SubscriptionDetailsProps = { note?: string; priceLabel: string; renewalLabel: string; seatsLabel?: string; usageLabel?: string; }; function getStatusLabel(status: SubscriptionCardStatus): string { switch (status) { case "active": return "Active"; case "canceled": return "Canceled"; case "past-due": return "Past due"; case "trialing": return "Trialing"; } } function getStatusClasses(status: SubscriptionCardStatus): string { switch (status) { case "active": return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; case "canceled": return "bg-muted text-muted-foreground"; case "past-due": return "bg-amber-500/10 text-amber-700 dark:text-amber-300"; case "trialing": return "bg-sky-500/10 text-sky-700 dark:text-sky-300"; } } function getPlanState( status: SubscriptionCardStatus, ): "current" | "legacy" | "trial" { switch (status) { case "active": case "past-due": return "current"; case "canceled": return "legacy"; case "trialing": return "trial"; } } function DetailRow({ label, value }: { label: string; value: string }) { return ( <div className="flex items-center justify-between gap-4 text-sm"> <span className="text-muted-foreground">{label}</span> <span className="text-right font-medium">{value}</span> </div> ); } function SubscriptionDetails({ note, priceLabel, renewalLabel, seatsLabel, usageLabel, }: SubscriptionDetailsProps) { return ( <CardContent className="space-y-4"> <div className="rounded-lg border border-border/70 bg-background px-4 py-3"> <p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"> Monthly total </p> <p className="mt-2 text-3xl font-semibold tracking-tight"> {priceLabel} </p> </div> <div className="space-y-3 rounded-lg border border-border/70 bg-muted/20 p-4"> <DetailRow label="Renewal" value={renewalLabel} /> {seatsLabel ? <DetailRow label="Seats" value={seatsLabel} /> : null} {usageLabel ? <DetailRow label="Usage" value={usageLabel} /> : null} </div> {note ? ( <p className="rounded-lg bg-muted px-4 py-3 text-sm text-muted-foreground"> {note} </p> ) : null} </CardContent> ); } function SubscriptionActions({ primaryActionLabel, secondaryActionLabel, }: SubscriptionActionsProps) { if (!primaryActionLabel && !secondaryActionLabel) { return null; } return ( <CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end"> {secondaryActionLabel ? ( <Button className="w-full sm:w-auto" variant="outline"> {secondaryActionLabel} </Button> ) : null} {primaryActionLabel ? ( <Button className="w-full sm:w-auto">{primaryActionLabel}</Button> ) : null} </CardFooter> ); } export const SubscriptionCard = React.forwardRef< React.ComponentRef<typeof Card>, SubscriptionCardProps >( ( { className, note, plan, priceLabel, primaryActionLabel, renewalLabel, seatsLabel, secondaryActionLabel, status, usageLabel, ...props }, reference, ) => { return ( <Card className={cn( "w-full max-w-md border-border/70 bg-card shadow-sm", className, )} ref={reference} {...props} > <CardHeader className="space-y-4 pb-4"> <div className="flex items-start justify-between gap-3"> <div className="space-y-1"> <CardTitle className="text-lg">Subscription</CardTitle> <CardDescription> Billing overview for the current workspace plan. </CardDescription> </div> <span className={cn( "inline-flex rounded-full px-2.5 py-1 text-xs font-medium", getStatusClasses(status), )} > {getStatusLabel(status)} </span> </div> <div className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/30 px-4 py-3"> <div> <p className="text-sm font-medium">Current plan</p> <p className="text-xs text-muted-foreground">{renewalLabel}</p> </div> <PlanBadge state={getPlanState(status)} tier={plan} /> </div> </CardHeader> <SubscriptionDetails note={note} priceLabel={priceLabel} renewalLabel={renewalLabel} seatsLabel={seatsLabel} usageLabel={usageLabel} /> <SubscriptionActions primaryActionLabel={primaryActionLabel} secondaryActionLabel={secondaryActionLabel} /> </Card> ); }, ); SubscriptionCard.displayName = "SubscriptionCard";

Dependencies

  • @vllnt/ui@^0.2.1