Pricing Card

Blocks

Plan 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.json

Ask 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

TryWhat happens
Toggle monthly/yearlyPrice digits roll; “Save 20%” badge pops next to yearly
MountFeature rows cascade with 14px check-draws at 0.05s

Props

PropTypeDefaultDescription
monthlyPrice / yearlyPricenumberPrices rolled by NumberFlow.
featuresstring[]Feature list rows.
featuredbooleanfalsePrimary 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>
  );
}