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.
npx shadcn@latest add @paragon/balance-card"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>
);
}