niceui

Card Folder

A wallet of payment cards that fans out on hover. Pick one and it rises out of the pocket.

ReactTailwind CSSTypeScriptBlocksMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/blocks-card-folder.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/blocks/CardFolder.tsx
import { useState } from 'react';
import { Check, Wallet } from 'lucide-react';
import { cn } from '@/lib/cn';

export type WalletCard = { id: string; brand: string; last4: string; holder: string; gradient: string; balance: string };

const CARDS: WalletCard[] = [
  { id: 'a', brand: 'Visa', last4: '4242', holder: 'Mira Chen', gradient: 'from-brand-400 via-brand-600 to-brand-900', balance: '$4,820.12' },
  { id: 'b', brand: 'Mastercard', last4: '8812', holder: 'Mira Chen', gradient: 'from-amber-300 via-orange-500 to-rose-600', balance: '$1,204.50' },
  { id: 'c', brand: 'Amex', last4: '1005', holder: 'Mira Chen', gradient: 'from-emerald-300 via-teal-500 to-sky-700', balance: '$12,930.00' },
  { id: 'd', brand: 'Debit', last4: '7731', holder: 'Mira Chen', gradient: 'from-ink-500 via-ink-700 to-ink-900', balance: '$389.77' },
];

export type CardFolderProps = { cards?: WalletCard[]; selected: string | null; onSelect: (id: string | null) => void };

export function CardFolder({ cards = CARDS, selected, onSelect }: CardFolderProps) {
  const [hover, setHover] = useState(false);
  const [lift, setLift] = useState<string | null>(null);
  const open = hover && !selected;
  const mid = (cards.length - 1) / 2;

  return (
    <div
      className="relative h-[300px] w-[300px]"
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      {/* back of wallet */}
      <div className="absolute inset-x-2 bottom-0 h-[150px] rounded-3xl bg-ink-800 shadow-card" />
      {cards.map((c, i) => {
        const isSel = selected === c.id;
        let transform = `translateX(-50%) translateY(${-60 - (cards.length - 1 - i) * 16}px)`;
        if (open) transform = `translateX(-50%) translateY(${lift === c.id ? -112 : -92}px) rotate(${(i - mid) * 11}deg) translateX(${(i - mid) * 6}px)`;
        if (selected) transform = isSel ? 'translateX(-50%) translateY(-138px) scale(1.04)' : `translateX(-50%) translateY(${-24 - (cards.length - 1 - i) * 6}px) scale(0.94)`;
        return (
          <button
            key={c.id}
            onClick={() => onSelect(isSel ? null : c.id)}
            onPointerEnter={() => setLift(c.id)}
            onPointerLeave={() => setLift(null)}
            aria-pressed={isSel}
            className={cn(
              'focus-ring absolute bottom-0 left-1/2 flex h-[160px] w-[256px] origin-bottom flex-col justify-between rounded-2xl bg-gradient-to-br p-4 text-left text-white shadow-pop transition-all duration-500 ease-spring',
              c.gradient,
            )}
            style={{ transform, zIndex: isSel ? 40 : i + 1, transitionDelay: open ? `${i * 30}ms` : '0ms' }}
          >
            <div className="flex items-start justify-between">
              <span className="text-sm font-semibold tracking-wide">{c.brand}</span>
              <span className={cn('flex h-5 w-5 items-center justify-center rounded-full bg-white text-ink-900 transition-all duration-300', isSel ? 'scale-100 opacity-100' : 'scale-50 opacity-0')}>
                <Check className="h-3 w-3" />
              </span>
            </div>
            <div className="h-7 w-9 rounded-md bg-gradient-to-br from-amber-100/90 to-amber-400/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.15)]" />
            <div className="flex items-end justify-between">
              <span className="font-mono text-sm tracking-[0.2em]">•••• {c.last4}</span>
              <span className="text-[11px] text-white/70">{c.holder}</span>
            </div>
          </button>
        );
      })}
      {/* front pocket */}
      <div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 h-[118px] rounded-3xl bg-gradient-to-b from-ink-700 to-ink-850 shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_-8px_24px_-8px_rgba(0,0,0,0.6)]">
        <div className="absolute inset-x-6 top-3 border-t border-dashed border-white/10" />
        <div className="absolute bottom-4 left-5 flex items-center gap-2 text-ink-300">
          <Wallet className="h-4 w-4" />
          <span className="text-xs font-medium">{cards.length} cards</span>
        </div>
      </div>
    </div>
  );
}

export function CardFolderDemo() {
  const [selected, setSelected] = useState<string | null>(null);
  const card = CARDS.find((c) => c.id === selected);
  return (
    <div className="flex flex-col items-center gap-5 pt-4">
      <CardFolder selected={selected} onSelect={setSelected} />
      <div className="h-12 text-center">
        {card ? (
          <div key={card.id} className="animate-fade-up">
            <p className="text-lg font-medium tabular-nums text-ink-50">{card.balance}</p>
            <p className="text-xs text-ink-400">{card.brand} •• {card.last4} · tap again to return</p>
          </div>
        ) : (
          <p className="pt-3 text-xs text-ink-500">Hover the wallet to fan out · click a card to select</p>
        )}
      </div>
    </div>
  );
}