Fintech & Payments

Balance Card

A balance surface whose amount hides behind an 8px blur privacy mask until the eye toggle reveals it, then auto re-masks after a timeout.

Install

npx shadcn@latest add @paragon/balance-card

balance-card.tsx

"use client";

import * as React from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";

export interface BalanceCardProps extends React.ComponentProps<"div"> {
  /** The formatted balance, e.g. "$128,430.55". */
  balance: string;
  /** Small label above the amount. */
  label?: string;
  /** Account chip text, e.g. "Operating •••• 4821". */
  account?: string;
  /** Optional trailing sublabel, e.g. "Available". */
  sublabel?: string;
  /** Seconds the balance stays visible before auto re-masking. */
  revealSeconds?: number;
  /** Start revealed instead of masked. */
  defaultRevealed?: boolean;
}

/**
 * A balance surface whose amount hides behind an 8px blur privacy mask until
 * the eye toggle reveals it (blur animates to 0). After `revealSeconds` it
 * auto re-masks, so a glanced screen doesn't leave the figure exposed. The
 * blur is a filter transition — cheap, interruptible, reduced-motion aware
 * (it snaps instead of animating). The amount stays selectable only while
 * revealed.
 */
export function BalanceCard({
  balance,
  label = "Total balance",
  account,
  sublabel,
  revealSeconds = 10,
  defaultRevealed = false,
  className,
  ...props
}: BalanceCardProps) {
  const [revealed, setRevealed] = React.useState(defaultRevealed);
  const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);

  const clear = React.useCallback(() => {
    if (timeout.current) clearTimeout(timeout.current);
  }, []);

  const toggle = React.useCallback(() => {
    setRevealed((prev) => {
      const next = !prev;
      clear();
      if (next) {
        timeout.current = setTimeout(
          () => setRevealed(false),
          revealSeconds * 1000,
        );
      }
      return next;
    });
  }, [clear, revealSeconds]);

  React.useEffect(() => clear, [clear]);

  return (
    <div
      className={cn(
        "flex flex-col gap-4 rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-start justify-between gap-3">
        <span className="text-[13px] font-medium text-muted-foreground">
          {label}
        </span>
        {account && (
          <span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[12px] font-medium tabular-nums text-muted-foreground">
            {account}
          </span>
        )}
      </div>

      <div className="flex items-end justify-between gap-3">
        <div className="min-w-0">
          <span
            aria-hidden={!revealed}
            className={cn(
              "block text-3xl font-semibold tracking-tight tabular-nums transition-[filter] duration-300 ease-out motion-reduce:transition-none",
              revealed
                ? "select-text blur-0"
                : "pointer-events-none select-none blur-[8px]",
            )}
          >
            {balance}
          </span>
          <span className="sr-only">
            {revealed ? balance : "Balance hidden"}
          </span>
          {sublabel && (
            <span className="mt-1 block text-[13px] text-muted-foreground">
              {sublabel}
            </span>
          )}
        </div>

        <button
          type="button"
          onClick={toggle}
          aria-pressed={revealed}
          aria-label={revealed ? "Hide balance" : "Reveal balance"}
          className="pressable relative flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground shadow-border transition-colors duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          {revealed ? (
            <EyeOff className="size-4" aria-hidden />
          ) : (
            <Eye className="size-4" aria-hidden />
          )}
        </button>
      </div>
    </div>
  );
}