Back to library

cards

Budget Card

Animated monthly budget card with spend breakdown, month picker, and expandable details. Motion-powered.

Inspired by Watermelon UI · View source

Preview

Monthly Budget

$3,200

Monthly spending limit

Spent
$2,14567%
Remaining$1,055

Spending breakdown

Housing

$1,200

Food

$420

Transport

$240

Subscriptions

$185

Other

$100

Copy for AI

Copy for Lovable is the fastest way to move this component into another Lovable project — it copies the full prompt with dependencies, files, and a usage example. Copy all files gives you the raw source if you want to paste it manually.

Installation

npx shadcn@latest add https://registry.watermelon.sh/r/budget-card.json

Paste into a terminal — or paste into a Lovable chat and I'll run it for you.

Source files

components/ui/budget-card.tsx
'use client';

import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { ChevronDown, Check, X } from 'lucide-react';

interface BreakdownItem {
  label: string;
  amount: number;
  color: string;
}

interface BudgetCardProps {
  month: string;
  totalBudget: number;
  spentAmount: number;
  breakdown: BreakdownItem[];
  onViewDetails?: () => void;
  onMonthChange?: (month: string) => void;
}

const months = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
];

export const BudgetCard: React.FC<BudgetCardProps> = ({
  month: initialMonth,
  totalBudget,
  spentAmount,
  breakdown,
  onViewDetails,
  onMonthChange,
}) => {
  const [selectedMonth, setSelectedMonth] = useState(initialMonth);
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
  const [isDetailsOpen, setIsDetailsOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  const spentPercentage = Math.round((spentAmount / totalBudget) * 100);
  const remainingAmount = totalBudget - spentAmount;

  const smoothTransition = { duration: 1.5, ease: [0.19, 1, 0.22, 1] } as const;
  const cornerClass = 'absolute w-5 h-5 border-black/20 dark:border-white/20';

  // Mock Transactions
  const transactions = [
    {
      id: 1,
      date: 'Jul 12',
      name: 'Whole Foods',
      category: 'Groceries',
      amount: 84.5,
    },
    {
      id: 2,
      date: 'Jul 10',
      name: 'Rent Payment',
      category: 'Rent',
      amount: 1600.0,
    },
    {
      id: 3,
      date: 'Jul 08',
      name: 'Chevron Gas',
      category: 'Other',
      amount: 52.3,
    },
    {
      id: 4,
      date: 'Jul 05',
      name: 'Starbucks',
      category: 'Other',
      amount: 12.45,
    },
    {
      id: 5,
      date: 'Jul 02',
      name: 'Apple Subscription',
      category: 'Other',
      amount: 9.99,
    },
  ];

  // Click outside logic
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (
        dropdownRef.current &&
        !dropdownRef.current.contains(event.target as Node)
      ) {
        setIsDropdownOpen(false);
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  const handleMonthSelect = (m: string) => {
    setSelectedMonth(m);
    setIsDropdownOpen(false);
    if (onMonthChange) onMonthChange(m);
  };

  return (
    <div className="relative mx-auto w-full max-w-130">
      <motion.div
        initial={{ opacity: 0, y: 20, scale: 0.98 }}
        animate={{ opacity: 1, y: 0, scale: 1 }}
        transition={{ duration: 0.8, ease: [0.19, 1, 0.22, 1] }}
      >
        {/* Corner Borders */}
        <div
          className={`${cornerClass} top-0 left-0 z-40 border-t-[1.6px] border-l-[1.6px]`}
        />
        <div
          className={`${cornerClass} top-0 right-0 z-40 border-t-[1.6px] border-r-[1.6px]`}
        />
        <div
          className={`${cornerClass} bottom-0 left-0 z-40 border-b-[1.6px] border-l-[1.6px]`}
        />
        <div
          className={`${cornerClass} right-0 bottom-0 z-40 border-r-[1.6px] border-b-[1.6px]`}
        />

        <div className="relative w-full overflow-visible border-[1.6px] border-black/8 bg-white font-sans text-black shadow-sm dark:border-[#e3d4d4]/10 dark:bg-[#0B0B0B] dark:text-white dark:shadow-2xl">
          {/* Top Section */}
          <div className="p-6 pb-4 sm:p-8">
            <div className="mb-1 flex items-start justify-between">
              <p className="text-sm font-normal text-zinc-500 sm:text-[18px] dark:text-[#686868]">
                Monthly Budget
              </p>

              {/* --- Month Dropdown --- */}
              <div className="relative" ref={dropdownRef}>
                <button
                  onClick={() => setIsDropdownOpen(!isDropdownOpen)}
                  className="flex min-w-30 items-center justify-between gap-2 border-[1.6px] border-zinc-200 bg-transparent px-3 py-1 text-sm font-normal text-zinc-500 transition-colors hover:bg-zinc-50 sm:gap-4 sm:text-[18px] dark:border-[#1e1d1d] dark:text-[#686868] dark:hover:bg-[#1A1A1A]"
                >
                  {selectedMonth}
                  <motion.div animate={{ rotate: isDropdownOpen ? 180 : 0 }}>
                    <ChevronDown
                      size={16}
                      className="text-zinc-400 dark:text-[#7d7c7c]"
                    />
                  </motion.div>
                </button>

                <AnimatePresence>
                  {isDropdownOpen && (
                    <motion.div
                      initial={{ opacity: 0, y: 10, scale: 0.95 }}
                      animate={{ opacity: 1, y: 5, scale: 1 }}
                      exit={{ opacity: 0, y: 10, scale: 0.95 }}
                      className="absolute top-full right-0 z-100 w-48 overflow-hidden border-[1.6px] border-black/8 bg-white shadow-xl dark:border-[#1e1d1d] dark:bg-[#121212]"
                    >
                      <div className="custom-scrollbar max-h-60 overflow-y-auto py-1">
                        {months.map((m) => (
                          <button
                            key={m}
                            onClick={() => handleMonthSelect(m)}
                            className={`flex w-full items-center justify-between px-4 py-2.5 text-left text-sm transition-colors ${
                              selectedMonth === m
                                ? 'bg-zinc-100 font-medium text-zinc-900 dark:bg-white/5 dark:text-white'
                                : 'text-zinc-500 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:bg-white/2'
                            }`}
                          >
                            {m}
                            {selectedMonth === m && (
                              <Check
                                size={14}
                                className="text-zinc-900 dark:text-white"
                              />
                            )}
                          </button>
                        ))}
                      </div>
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>
            </div>

            <h2 className="mb-6 text-[40px] leading-none font-medium tracking-tight text-zinc-900 sm:mb-10 sm:text-[60px] dark:text-[#F4F4F4]">
              ${totalBudget.toLocaleString()}
            </h2>

            <div className="space-y-3">
              <p className="text-sm font-normal text-zinc-500 sm:text-[18px] dark:text-[#686868]">
                Monthly spending limit
              </p>
              <div className="h-2 w-full overflow-hidden bg-zinc-100 dark:bg-[#222222]">
                <motion.div
                  initial={{ width: 0 }}
                  animate={{ width: `${spentPercentage}%` }}
                  transition={smoothTransition}
                  className="h-full bg-linear-to-r from-zinc-400 to-zinc-800 dark:from-[#424243] dark:to-white/90"
                />
              </div>
              <div className="flex items-end justify-between pt-1">
                <div className="space-y-1.5">
                  <span className="block text-[12px] font-normal text-zinc-400 capitalize sm:text-[14px] dark:text-[#7e7d7d]">
                    Spent
                  </span>
                  <div className="flex items-center gap-2 sm:gap-4">
                    <span className="text-[16px] font-normal text-zinc-600 sm:text-[20px] dark:text-[#B3B3B3]">
                      ${spentAmount.toLocaleString()}
                    </span>
                    <span className="border border-black/5 bg-gray-100 px-1.5 py-[1.75px] text-[12px] font-normal text-zinc-500 dark:border-white/10 dark:bg-black dark:bg-linear-to-t dark:from-[#010101]/10 dark:to-white/10 dark:text-[#f1f1f1]/40">
                      {spentPercentage}%
                    </span>
                  </div>
                </div>
                <div className="space-y-2 text-right">
                  <span className="block text-[14px] font-normal text-zinc-400 capitalize sm:text-[16px] dark:text-[#7e7d7d]">
                    Remaining
                  </span>
                  <span className="text-[16px] font-normal text-zinc-600 sm:text-[20px] dark:text-[#B3B3B3]">
                    ${remainingAmount.toLocaleString()}
                  </span>
                </div>
              </div>
            </div>
          </div>

          <div className="h-px w-full bg-black/5 dark:bg-white/5" />

          {/* Bottom Section  */}
          <div className="space-y-6 p-6 pt-7 sm:p-8">
            <div className="space-y-3">
              <p className="block text-[14px] font-normal text-zinc-400 capitalize sm:text-[16px] dark:text-[#7e7d7d]">
                Spending breakdown
              </p>
              <div className="flex h-2 gap-2">
                {breakdown.map((item, idx) => (
                  <motion.div
                    key={idx}
                    initial={{ scaleX: 0 }}
                    animate={{ scaleX: 1 }}
                    transition={{ ...smoothTransition, delay: 0.5 + idx * 0.1 }}
                    className="h-full origin-left"
                    style={{ flex: item.amount, background: item.color }}
                  />
                ))}
              </div>
              <div className="grid grid-cols-3 gap-2 sm:gap-4">
                {breakdown.map((item, idx) => (
                  <div className="flex flex-col gap-1" key={idx}>
                    <p className="block truncate text-[12px] font-normal text-zinc-400 capitalize sm:text-[14px] dark:text-[#7e7d7d]">
                      {item.label}
                    </p>
                    <p className="text-[14px] font-normal text-zinc-600 sm:text-[18px] dark:text-[#B3B3B3]">
                      ${item.amount.toLocaleString()}
                    </p>
                  </div>
                ))}
              </div>
            </div>
            <button
              onClick={() => {
                setIsDetailsOpen(true);
                if (onViewDetails) onViewDetails();
              }}
              className="w-full border-[1.6px] border-zinc-200 bg-transparent py-3 text-[14px] font-normal text-zinc-800 transition-colors hover:bg-zinc-50 dark:border-[#1e1d1d]/90 dark:text-[#f1f1f1] dark:hover:bg-white/5"
            >
              View Details
            </button>
          </div>
        </div>
      </motion.div>

      {/* --- Details Modal --- */}
      <AnimatePresence>
        {isDetailsOpen && (
          <div className="absolute inset-0 z-100 flex items-center justify-center p-4">
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={() => setIsDetailsOpen(false)}
              className="absolute inset-0 bg-white/60 backdrop-blur-md dark:bg-black/80"
            />
            <motion.div
              initial={{ y: 20, opacity: 0, scale: 0.95 }}
              animate={{ y: 0, opacity: 1, scale: 1 }}
              exit={{ y: 20, opacity: 0, scale: 0.95 }}
              transition={{ type: 'spring', damping: 25, stiffness: 300 }}
              className="relative flex max-h-[90%] w-full max-w-lg flex-col overflow-hidden border-[1.6px] border-black/8 bg-white shadow-2xl dark:border-white/10 dark:bg-[#0B0B0B]"
            >
              {/* Modal Header */}
              <div className="flex items-center justify-between border-b border-black/5 p-6 dark:border-white/5">
                <div>
                  <h3 className="text-lg font-semibold text-zinc-900 dark:text-white">
                    Budget Details
                  </h3>
                  <p className="text-xs text-zinc-500 dark:text-zinc-400">
                    Deep dive into your {selectedMonth} spending
                  </p>
                </div>
                <button
                  onClick={() => setIsDetailsOpen(false)}
                  className="rounded-full p-2 transition-colors hover:bg-zinc-100 dark:hover:bg-white/5"
                >
                  <X size={20} className="text-zinc-500 dark:text-zinc-400" />
                </button>
              </div>

              {/* Modal Content */}
              <div className="custom-scrollbar flex-1 space-y-8 overflow-y-auto p-6">
                {/* Stats Grid */}
                <div className="grid grid-cols-2 gap-3 sm:gap-4">
                  <div className="rounded-lg border border-black/5 bg-zinc-50 p-3 transition-colors sm:p-4 dark:border-white/5 dark:bg-white/2">
                    <p className="mb-1 truncate text-[9px] tracking-wide text-zinc-400 uppercase sm:text-[10px]">
                      Total Budget
                    </p>
                    <p className="text-lg leading-tight font-medium text-zinc-900 sm:text-xl dark:text-white">
                      ${totalBudget.toLocaleString()}
                    </p>
                  </div>
                  <div className="rounded-lg border border-black/5 bg-zinc-50 p-3 transition-colors sm:p-4 dark:border-white/5 dark:bg-white/2">
                    <p className="mb-1 truncate text-[9px] tracking-wide text-zinc-400 uppercase sm:text-[10px]">
                      Amount Spent
                    </p>
                    <p className="text-lg leading-tight font-medium text-zinc-600 sm:text-xl dark:text-zinc-300">
                      ${spentAmount.toLocaleString()}
                    </p>
                  </div>
                </div>

                {/* Category Breakdown (Detailed) */}
                <div className="space-y-4">
                  <h4 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
                    Category Breakdown
                  </h4>
                  <div className="space-y-4">
                    {breakdown.map((item, idx) => {
                      const percentage = Math.round(
                        (item.amount / spentAmount) * 100,
                      );
                      return (
                        <div key={idx} className="space-y-2">
                          <div className="flex justify-between text-sm">
                            <span className="font-medium text-zinc-600 dark:text-zinc-300">
                              {item.label}
                            </span>
                            <span className="text-zinc-400">{percentage}%</span>
                          </div>
                          <div className="h-1.5 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-900">
                            <motion.div
                              initial={{ width: 0 }}
                              animate={{ width: `${percentage}%` }}
                              className="h-full rounded-full"
                              style={{ background: item.color }}
                            />
                          </div>
                        </div>
                      );
                    })}
                  </div>
                </div>

                {/* Transactions History */}
                <div className="space-y-4">
                  <h4 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
                    Recent Transactions
                  </h4>
                  <div className="divide-y divide-black/5 border-t border-black/5 dark:divide-white/5 dark:border-white/5">
                    {transactions.map((t) => (
                      <div
                        key={t.id}
                        className="group -mx-2 flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-3 transition-colors hover:bg-zinc-50 sm:gap-4 dark:hover:bg-white/1"
                      >
                        <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
                          <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[10px] font-bold text-zinc-400 dark:bg-white/5">
                            {t.name[0]}
                          </div>
                          <div className="min-w-0 flex-1">
                            <p className="truncate text-sm font-medium tracking-tight text-zinc-900 uppercase dark:text-white">
                              {t.name}
                            </p>
                            <p className="truncate text-[10px] text-zinc-500">
                              {t.category} • {t.date}
                            </p>
                          </div>
                        </div>
                        <span className="flex-shrink-0 text-sm font-medium text-zinc-900 dark:text-zinc-300">
                          -${t.amount.toFixed(2)}
                        </span>
                      </div>
                    ))}
                  </div>
                </div>
              </div>

              {/* Modal Footer */}
              <div className="border-t border-black/5 p-6 dark:border-white/5">
                <button
                  onClick={() => setIsDetailsOpen(false)}
                  className="w-full bg-zinc-900 py-3 font-medium text-white transition-transform active:scale-[0.98] dark:bg-white dark:text-black"
                >
                  Close Insights
                </button>
              </div>
            </motion.div>
          </div>
        )}
      </AnimatePresence>
    </div>
  );
};

Dependencies

motionlucide-react