Pricing Card
BlocksPlan card with billing toggle; the price rolls via NumberFlow and a savings badge pops in on yearly.
Most popular
Pro
For teams shipping motion-heavy products.
$49/mo
- Every component in the catalog
- Registry access + updates
- Unlimited projects
- Priority support
Install
npx shadcn@latest add https://labs.duku.design/r/pricing-card.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Pricing Card component (get_component "pricing-card", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Toggle monthly/yearly | Price digits roll; “Save 20%” badge pops next to yearly |
| Mount | Feature rows cascade with 14px check-draws at 0.05s |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| monthlyPrice / yearlyPrice | number | — | Prices rolled by NumberFlow. |
| features | string[] | — | Feature list rows. |
| featured | boolean | false | Primary border + “Most popular” badge. |
Source — registry/default/blocks/pricing-card.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { Button } from "@/registry/default/ui/button";
import { Badge } from "@/registry/default/ui/badge";
import { NumberFlow } from "@/registry/default/fintech/number-flow";
export interface PricingCardProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
plan: string;
description?: string;
monthlyPrice: number;
yearlyPrice: number;
currencyPrefix?: string;
features: string[];
featured?: boolean;
ctaLabel?: string;
savingsLabel?: string;
onSelect?: (billing: "monthly" | "yearly") => void;
ref?: React.Ref<HTMLDivElement>;
}
function FeatureCheck({ delay }: { delay: number }) {
const reduced = useReducedMotion();
const [drawn, setDrawn] = React.useState(false);
React.useEffect(() => {
if (reduced) {
setDrawn(true);
return;
}
const t = setTimeout(() => setDrawn(true), delay);
return () => clearTimeout(t);
}, [delay, reduced]);
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="mt-0.5 size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
aria-hidden="true"
>
<path
d="M5 13l4 4L19 7"
pathLength={1}
strokeDasharray={1}
strokeDashoffset={drawn ? 0 : 1}
className="transition-[stroke-dashoffset] duration-300 ease-out"
/>
</svg>
);
}
export function PricingCard({
plan,
description,
monthlyPrice,
yearlyPrice,
currencyPrefix = "$",
features,
featured = false,
ctaLabel = "Get started",
savingsLabel = "Save 20%",
onSelect,
className,
ref,
...rest
}: PricingCardProps) {
const [billing, setBilling] = React.useState<"monthly" | "yearly">(
"monthly"
);
const rootRef = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const springStandard = {
type: "spring",
stiffness: 380,
damping: 32,
} as const;
// Feature rows cascade on mount.
useGSAP(
() => {
const root = rootRef.current;
if (!root) return;
const rows = gsap.utils.toArray<HTMLElement>(
root.querySelectorAll('[data-slot="feature-row"]')
);
if (reduced) {
gsap.set(rows, { y: 0, opacity: 1 });
return;
}
gsap.fromTo(
rows,
{ y: 12, opacity: 0 },
{
y: 0,
opacity: 1,
duration: 0.35,
ease: "power2.out",
stagger: 0.05,
delay: 0.1,
}
);
},
{ scope: rootRef }
);
const price = billing === "monthly" ? monthlyPrice : yearlyPrice;
return (
<div
ref={rootRef}
data-slot="pricing-card"
className={cn(
"relative w-full max-w-sm rounded-2xl border bg-card p-6 shadow-sm",
featured ? "border-primary" : "border-border",
className
)}
{...rest}
>
{featured ? (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge>Most popular</Badge>
</div>
) : null}
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">{plan}</h3>
{/* billing toggle: two-option sliding pill */}
<div
data-slot="billing-toggle"
role="tablist"
aria-label="Billing period"
className="relative inline-flex items-center rounded-lg bg-muted p-0.5"
>
{(["monthly", "yearly"] as const).map((b) => (
<button
key={b}
role="tab"
aria-selected={billing === b}
onClick={() => {
setBilling(b);
onSelect?.(b);
}}
className={cn(
"relative z-10 rounded-md px-2.5 py-1 text-xs font-medium capitalize",
"transition-colors duration-200",
billing === b
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
{billing === b ? (
<motion.span
layoutId={`billing-pill-${plan}`}
aria-hidden="true"
className="absolute inset-0 -z-10 rounded-md bg-background shadow-sm"
transition={reduced ? { duration: 0 } : springStandard}
/>
) : null}
{b}
</button>
))}
</div>
</div>
{description ? (
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
) : null}
<div className="mt-5 flex items-baseline gap-2">
<span className="text-4xl font-semibold tracking-tight text-foreground">
<NumberFlow value={price} prefix={currencyPrefix} />
</span>
<span className="text-sm text-muted-foreground">
/{billing === "monthly" ? "mo" : "yr"}
</span>
<AnimatePresence>
{billing === "yearly" ? (
<motion.span
initial={
reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96 }
}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={
reduced ? { duration: 0 } : { duration: 0.25, ease: "easeOut" }
}
>
<Badge variant="success">{savingsLabel}</Badge>
</motion.span>
) : null}
</AnimatePresence>
</div>
<ul className="mt-6 flex flex-col gap-2.5">
{features.map((feature, i) => (
<li
key={feature}
data-slot="feature-row"
className="flex items-start gap-2.5 text-sm text-foreground opacity-0"
>
<FeatureCheck delay={150 + i * 50} />
{feature}
</li>
))}
</ul>
<div className="mt-6">
<Button
className="w-full"
variant={featured ? "default" : "outline"}
onClick={() => onSelect?.(billing)}
>
{ctaLabel}
</Button>
</div>
</div>
);
}