# Wallet

> A balance you can send from: the balance panel turns into the transfer, the button goes Sending → Sent, and the money visibly leaves.

Most wallet cards open a modal to send money, flash a toast, and change the balance without a trace of what happened. Here the card is the whole flow. Send turns the balance panel itself into the transfer: the figure gives way to the amount you type, each new place rolling open, while the activity folds away and a keypad folds in under the people you can pay. The button says what it will do ("Send $40.00"), then goes Sending → Sent with a check that draws itself. The panel turns back, the balance rolls down by exactly that amount and the new payment slides into Activity, so you see where the money went. Requests work the same way and show as a pending row until your server marks them paid, when "Requested" morphs into "Paid" and the balance rolls up. The card never holds the money: you pass the balance and the history, and do the transfer in onSend and onRequest.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/wallet.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `copy-button`, `text-morph`, `number-roll`, `status-button`
- Docs and live preview: https://hairlineui.com/ui/wallet
- Registry item (source): https://hairlineui.com/r/wallet.json

## Usage

```tsx
import { Wallet } from "@/components/wallet";

<Wallet
  name="Kai Morgan"
  balance={balance}
  contacts={contacts}
  transactions={transactions}
  onSend={async ({ contact, amount, note }) => { /* move the money */ }}
  onRequest={async ({ contact, amount, note }) => { /* create the request */ }}
/>
```

## With your own API

Keep the balance and history in your state, do the transfer on the server, and update both when it succeeds. Throwing shows your message on the card.

```tsx
"use client";
import { useState } from "react";
import { Wallet, type WalletContact, type WalletTransaction, type WalletTransfer } from "@/components/wallet";

export function HomeWallet({ initial }: { initial: { balance: number; transactions: WalletTransaction[]; contacts: WalletContact[] } }) {
  const [balance, setBalance] = useState(initial.balance);
  const [transactions, setTransactions] = useState(initial.transactions);

  const onSend = async (t: WalletTransfer) => {
    const res = await fetch("/api/transfers", {
      method: "POST",
      body: JSON.stringify({ to: t.contact.id, amount: t.amount, note: t.note }),
    });
    if (!res.ok) throw new Error((await res.json()).message ?? "That didn't go through.");
    const { balance, transaction } = await res.json();
    setBalance(balance);
    setTransactions((list) => [transaction, ...list]);
  };

  const onRequest = async (t: WalletTransfer) => {
    const res = await fetch("/api/requests", { method: "POST", body: JSON.stringify({ from: t.contact.id, amount: t.amount }) });
    if (!res.ok) throw new Error("Couldn't send the request.");
    setTransactions((list) => [{ ...(await res.json()), status: "pending" }, ...list]);
  };

  return <Wallet name="Kai Morgan" label="Everyday" last4="4021" balance={balance} contacts={initial.contacts} transactions={transactions} onSend={onSend} onRequest={onRequest} />;
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `balance` | `number` | – | The available balance in major units. Changes roll in the direction they moved. |
| `transactions` | `WalletTransaction[]` | `[]` | Newest first: { id, title, date, amount, status?, src?, icon? }. Negative amounts are money out; status "pending" shows a request until it’s "paid"; icon puts a merchant’s logo on a tinted tile. |
| `contacts` | `WalletContact[]` | `[]` | People to pay or ask: { id, name, src? }. Without a photo, initials on a tint from your chart colours, the same tint for the same person everywhere. |
| `onSend` | `(transfer: WalletTransfer) => Promise<unknown>` | – | Move the money; resolve when it’s done, throw an Error to show its message. |
| `onRequest` | `(transfer: WalletTransfer) => Promise<unknown>` | – | Ask for money; resolve once the request exists and add it to transactions as pending. |
| `name` | `string` | `"You"` | The account holder, on the card. |
| `label / last4` | `string` | `"Balance" / —` | What the account is called, at the top of the card, and the last four digits, shown masked. |
| `currency / locale` | `string` | `"USD" / browser` | Formats every amount with Intl, including the decimal key on the keypad. |
| `paymentLink` | `string` | – | Shown with a copy button while requesting. |
| `hidden / defaultHidden / onHiddenChange` | `boolean / boolean / (hidden) => void` | `— / false` | Whether the balance is masked; controlled or not. |
| `visibleTransactions` | `number` | `2` | Rows of Activity before See all. |

## States

- **home**: A card-shaped panel in your --primary, lit from its top corner and engraved with fine hairlines, holds the account, the available balance, the holder and the last four digits; Request and Send sit under it, then the people you pay often and the latest two rows of Activity (See all opens the rest in place).
- **hidden**: The eye folds the balance into dots through a 6px blur; "Available" while sending is masked too.
- **send / request**: The card turns into the transfer in 460ms: the title morphs ("Send to Iris"), the typed amount rolls open place by place, the actions and Activity fold away and the keypad, note and button fold in.
- **pending**: The button morphs to Sending with a spinner; the keypad and people are locked until it settles.
- **sent**: A check draws itself, the panel turns back, then the balance rolls down and the new row slides in at the top of Activity.
- **error**: Your error message replaces the hint under the amount, the button shakes and reads Try again; the amount and note are kept.
- **paid**: A pending request whose status turns paid morphs "Requested" into "Paid" and the balance rolls up.

## What it handles

A wallet screen is usually three pieces glued together: a balance card, a send modal with an amount field, and a toast. This is one surface that does the whole round trip, against your own backend.

- **The panel becomes the transfer**: Send and Request turn the balance panel into the amount you type, with no modal on top of the card.
- **Amounts typed safely**: Digits are kept as text, two decimals at most, from the keypad or the keyboard (Enter sends, Escape cancels).
- **The button says what happens**: It reads "Send $40.00" or why it can’t yet, then Sending, Sent with a drawn check, or Try again.
- **You see where the money went**: After it’s sent the balance rolls down by the amount and the payment slides into Activity.
- **Requests until they’re paid**: A request shows as a pending row; when you mark it paid the label morphs and the balance rolls up.
- **Your server stays in charge**: Balance and history come from your props; onSend and onRequest do the work and their errors are shown in place.

## Replaces

a send-money modal, an amount keypad, a recipient picker, a pending/success toast, an activity list, a hide-balance toggle

## Where it fits

- **Fintech and neobank apps**: The home card: balance, people you pay often, and the latest activity.
- **Marketplace payouts**: A seller’s earnings with a way to send or request money from buyers.
- **Shared expenses**: Split and settle up between friends, with requests that show until they’re paid.
- **In-app credits**: A balance of credits or points that people can send to teammates.

## Notes

- The transfer is a labelled group that takes focus when it opens; digits, the decimal point, Backspace, Enter and Escape work from the keyboard anywhere in the card.
- People are a radio group while choosing who to pay, and buttons ("Send to Iris") on the home view.
- The balance and the amount are read as whole numbers from sr-only text, never digit by digit; sent and requested are announced from a status region beside the card.
- Errors are alerts and keep what you typed. While a transfer is pending the card can’t be cancelled.
- Its light comes from your theme: the card mixes --primary-foreground into --primary for the lit corner, 1px edges catch the light, and avatar tints come from --chart-1 to --chart-5, so it takes on your colours in light and dark. No drop shadows, glow or blur.
- Built from Number roll, Text morph, Status button and Copy button, which the CLI installs with it. No animation library; with reduced motion the card and rows change in place.

License: MIT (https://hairlineui.com/license#components)
