# Payments

Guides send, receive, and deposit flows, from entering an amount to keeping the receipt.

## Overview

A payment moves money and can't be undone. Each step has to show exactly what will happen, catch mistakes before you confirm, and report the outcome plainly. Tempo Wallet (wallet.tempo.xyz) uses three flows: Send, Receive, and Add funds.

This page combines:

* `AmountInput` inside `Field` for the amount, and `Input` for the recipient.
* `Select` with `Token` for choosing the asset.
* `Rows`, `Amount`, `Hex`, and `Identicon` for the review step and the receipt.
* `Button` with `size="large"` and `loading` for the confirm action.
* `Stepper`, `Frame`, and `Surface` for the flow's frame.
* `Spinner`, `Callout`, and `Toast` for pending, failure, and confirmation.
* `Qr` and `CopyButton` for receiving funds.

> [!NOTE]
> The live examples are reference layouts composed from library components and `style`. They simulate the network, so no funds move.

## Recommended Structure

Run each flow as a focused flow: a centered column about 28rem wide with global navigation hidden, or a `Dialog`, which becomes a bottom sheet on narrow screens.

1. **Details.** The asset, the amount, and the recipient.
2. **Review.** A summary of exactly what will happen, with the confirm action.
3. **Status.** Pending, then sent or failed, on the same screen.
4. **Receipt.** A record you can return to from activity.

Try the flow below. Enter an amount and a recipient address, review the payment, and confirm it.

```tsx
import { Amount, AmountInput, Button, Field, formatHex, Frame, Hex, Identicon, Input, Rows, Stack, Stepper, style, Surface } from '@tempoxyz/ds'
import { CheckCircle } from '@tempoxyz/ds/icons'
import { useEffect, useRef, useState } from 'react'

export const fill = true
const balance = '1240.50'
const fee = '0.00003'
const hash = '0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060'

function valid(address: string) {
  return /^0x[0-9a-fA-F]{40}$/.test(address)
}

// Adds decimal strings in 6-decimal base units, so the total never picks up floating-point error.
function add(a: string, b: string) {
  const units = (value: string) => {
    const [whole = '', fraction = ''] = value.split('.')
    return BigInt(`${whole || '0'}${fraction.padEnd(6, '0').slice(0, 6)}`)
  }
  const digits = (units(a) + units(b)).toString().padStart(7, '0')
  return `${digits.slice(0, -6)}.${digits.slice(-6).replace(/0+$/, '').padEnd(2, '0')}`
}

export function Demo() {
  const [step, setStep] = useState<'details' | 'review' | 'sent'>('details')
  const [amount, setAmount] = useState('')
  const [recipient, setRecipient] = useState('')
  // Errors show after a field loses focus or the form submits, never while you type.
  const [touched, setTouched] = useState({ amount: false, recipient: false })
  const [sending, setSending] = useState(false)
  const heading = useRef<HTMLHeadingElement>(null)
  const previous = useRef(step)

  // Each step replaces the last, so focus moves to the new heading instead of falling back to the page.
  useEffect(() => {
    if (previous.current !== step) heading.current?.focus()
    previous.current = step
  }, [step])

  const amountError = (() => {
    if (!touched.amount) return undefined
    if (!Number(amount)) return 'Enter an amount to send.'
    if (Number(amount) > Number(balance)) return 'Enter an amount up to 1,240.50 PathUSD.'
    return undefined
  })()
  const recipientError =
    touched.recipient && !valid(recipient)
      ? 'Enter an address that starts with 0x and has 40 more characters.'
      : undefined

  if (step === 'sent')
    return (
      <Surface {...styles.card()}>
        <Frame.Root>
          <div {...styles.result()}>
            <CheckCircle aria-hidden="true" {...styles.success()} />
            <Frame.Title ref={heading} tabIndex={-1}>
              Payment sent
            </Frame.Title>
            <Amount size="md" symbol="PathUSD" value={amount} />
            <p {...styles.text()}>To {formatHex(recipient)}</p>
          </div>
          <Rows.Root dividers="full" {...styles.rows()}>
            <Rows.Row>
              <Rows.Label>Transaction</Rows.Label>
              <Hex copy value={hash} />
            </Rows.Row>
          </Rows.Root>
          <Frame.Footer>
            <Button
              onClick={() => {
                setAmount('')
                setRecipient('')
                setTouched({ amount: false, recipient: false })
                setStep('details')
              }}
              size="large"
            >
              Done
            </Button>
          </Frame.Footer>
        </Frame.Root>
      </Surface>
    )

  if (step === 'review')
    return (
      <Surface {...styles.card()}>
        <Frame.Root>
          <Stack gap="3">
            <Stepper.Root aria-label="Send payment" count={2} value={2} />
            <Frame.Title ref={heading} tabIndex={-1}>
              Review payment
            </Frame.Title>
          </Stack>
          <Amount size="lg" symbol="PathUSD" value={amount} />
          {/* The review shows the whole address, so you can check it against where you got it. */}
          <div {...styles.recipient()}>
            <span {...styles.label()}>To</span>
            <span {...styles.address()}>
              <Identicon aria-hidden address={recipient} size="24" />
              <code {...styles.code()}>{recipient}</code>
            </span>
          </div>
          <Rows.Root dividers="full" {...styles.rows()}>
            <Rows.Row>
              <Rows.Label>Network fee</Rows.Label>
              <Amount symbol="PathUSD" value={fee} />
            </Rows.Row>
            <Rows.Row>
              <Rows.Label>Total</Rows.Label>
              <Amount symbol="PathUSD" value={add(amount, fee)} />
            </Rows.Row>
          </Rows.Root>
          <Frame.Footer {...styles.footer()}>
            <Button
              loading={sending}
              onClick={() => {
                setSending(true)
                setTimeout(() => {
                  setSending(false)
                  setStep('sent')
                }, 1500)
              }}
              size="large"
            >
              Confirm and send
            </Button>
            <Button disabled={sending} onClick={() => setStep('details')} variant="text">
              Edit payment
            </Button>
          </Frame.Footer>
        </Frame.Root>
      </Surface>
    )

  return (
    <Surface
      render={
        <form
          noValidate
          onSubmit={(event) => {
            event.preventDefault()
            setTouched({ amount: true, recipient: true })
            const amountValid = Number(amount) > 0 && Number(amount) <= Number(balance)
            if (amountValid && valid(recipient)) return setStep('review')

            // Focus moves to the first field that needs fixing.
            const name = amountValid ? 'recipient' : 'amount'
            event.currentTarget.querySelector<HTMLInputElement>(`input[name="${name}"]`)?.focus()
          }}
        />
      }
      {...styles.card()}
    >
      <Frame.Root>
        <Stack gap="3">
          <Stepper.Root aria-label="Send payment" count={2} value={1} />
          <Frame.Title ref={heading} tabIndex={-1}>
            Send
          </Frame.Title>
        </Stack>
        <Field.Root invalid={amountError !== undefined} variant="card">
          <Field.Label>Amount</Field.Label>
          <AmountInput.Root
            onValueChange={(value) => {
              setAmount(value)
              setTouched({ ...touched, amount: false })
            }}
            value={amount}
          >
            <AmountInput.Control
              name="amount"
              onBlur={() => setTouched({ ...touched, amount: amount !== '' })}
              placeholder="0.00"
            />
            <AmountInput.Suffix>PathUSD</AmountInput.Suffix>
            <AmountInput.Max aria-label="Send full balance" onClick={() => setAmount(balance)} />
          </AmountInput.Root>
          <Field.Description>Balance: 1,240.50 PathUSD</Field.Description>
          <Field.Error match={amountError !== undefined}>{amountError}</Field.Error>
        </Field.Root>
        <Field.Root invalid={recipientError !== undefined}>
          <Field.Label>Recipient</Field.Label>
          <Input.Group>
            <Input.Control
              autoComplete="off"
              name="recipient"
              onBlur={() => setTouched({ ...touched, recipient: recipient !== '' })}
              onValueChange={(value) => {
                setRecipient(value.trim())
                setTouched({ ...touched, recipient: false })
              }}
              placeholder="0x…"
              spellCheck={false}
              value={recipient}
            />
          </Input.Group>
          <Field.Description>Paste a Tempo address.</Field.Description>
          <Field.Error match={recipientError !== undefined}>{recipientError}</Field.Error>
        </Field.Root>
        <Frame.Footer>
          <Button type="submit">Review payment</Button>
        </Frame.Footer>
      </Frame.Root>
    </Surface>
  )
}

namespace styles {
  export const address = style({ alignItems: 'center', columnGap: '3', display: 'flex' })

  export const card = style({
    boxSizing: 'border-box',
    marginInline: 'auto !custom',
    maxWidth: 'md',
    width: '100% !custom',
  })

  export const code = style({ minWidth: '0', typography: 'copy.13.mono', wordBreak: 'break-all' })

  export const footer = style({ rowGap: '2' })

  export const label = style({ color: 'gray.900', typography: 'label.13' })

  export const recipient = style({ display: 'flex', flexDirection: 'column', rowGap: '2' })

  export const result = style({
    alignItems: 'center',
    display: 'flex',
    flexDirection: 'column',
    rowGap: '2',
    textAlign: 'center',
  })

  // Surface pads 24px, so the rows reach its edges and keep their own 20px inset.
  export const rows = style({ marginInline: '-24px !custom' })

  export const success = style({ color: 'green.700', fontSize: '4xl' })

  export const text = style({ color: 'gray.900', margin: '0', typography: 'copy.14' })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/PaymentsSend.tsx)

## Enter the Amount

* Place `AmountInput` in a `Field` with `variant="card"` and a visible `Field.Label`, such as Amount.
* Show the unit with `AmountInput.Suffix`, such as PathUSD, or `AmountInput.Prefix` with `$` for dollar entry.
* Show the available balance in `Field.Description`, such as Balance: 1,240.50 PathUSD.
* `AmountInput` keeps the value as a raw decimal string and rejects anything else. Keep it a string until you convert it to base units. TIP-20 tokens use 6 decimal places.
* Wire `AmountInput.Max` to set the balance, and name it by what it fills, such as Send full balance. When the network fee comes out of the same token, fill the balance minus the fee.
* Validate when the field loses focus and when the form submits, never on each keystroke. Write errors that name the limit, such as Enter an amount up to 1,240.50 PathUSD.

When the account holds several tokens, put a `Select` above the amount, with a `Token` icon in `Select.ItemPrefix` and the balance in `Select.ItemSuffix`. Default to the token used last.

## Enter the Recipient

* Use `Input` with `autoComplete="off"` and `spellCheck={false}`, a placeholder of `0x…`, and a label such as Recipient.
* Trim spaces from pasted addresses. Check the format when the field loses focus: `0x` followed by 40 hexadecimal characters.
* Explain a bad address in `Field.Error`, such as Enter an address that starts with 0x and has 40 more characters.
* Show a saved contact's name with an `Identicon` beside the address once it matches. Never show the `Identicon` alone.
* Warn with a `Callout` when the address is new to the account, such as You haven't sent to this address before.

## Review Before Confirming

The review step repeats every detail that affects the payment, in `Rows` with a `Rows.Label` for each.

| Row         | Content                                                                                                                                    |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Amount      | The amount and token, shown large with `Amount` and `size="lg"`.                                                                           |
| To          | The recipient's `Identicon`, the full address, and the contact name when known. Ask people to check the address against where they got it. |
| Network fee | The fee in the token that pays it, such as 0.00003 PathUSD. Say Paid by and the sponsor's name when a fee payer covers it.                 |
| Total       | The amount plus the fee, when both come out of the same token.                                                                             |
| Memo        | The reference attached to the transfer, such as an invoice number, when there is one.                                                      |

**Do:** Show the fee as its own row and the total beneath it.

**Don't:** Fold the fee into a total without showing it.

Show sub-cent fees exactly. A `symbol` amount keeps every fraction digit you pass, and a `currency` amount needs `precise` to show $0.00003 instead of `<$0.01`.

### Confirm With One Large Action

End the review with one `primary` `Button` with `size="large"` at full width, labelled with the verb and object, such as Confirm and send. Put a `text` Edit payment action beneath it.

**Do:** Use one large, specific confirm action with a quiet way back.

**Don't:** Place a small, generic confirm among other actions.

Set `loading` on the confirm button while the wallet signs and submits. It keeps its width and blocks a second press, so the payment can't be sent twice.

## Report the Outcome

Switch between the states below.

```tsx
import { Amount, Button, ChoiceGroup, Frame, Spinner, Stack, style, Surface } from '@tempoxyz/ds'
import { AlertCircle, CheckCircle } from '@tempoxyz/ds/icons'
import { useState } from 'react'

export const fill = true
type State = 'failed' | 'pending' | 'sent'

const states = {
  failed: {
    description: 'Your balance hasn’t changed. Check the recipient and amount, then try again.',
    primary: 'Try again',
    secondary: 'Edit payment',
    title: 'Payment failed',
  },
  pending: {
    description: 'Sending to 0x71C7…976F. You can close this screen.',
    primary: undefined,
    secondary: undefined,
    title: 'Sending payment',
  },
  sent: {
    description: 'Sent to 0x71C7…976F.',
    primary: 'Done',
    secondary: 'View receipt',
    title: 'Payment sent',
  },
}

export function Demo() {
  const [state, setState] = useState<State>('pending')
  const content = states[state]

  return (
    <Stack gap="4">
      <ChoiceGroup.Root
        aria-label="Payment state"
        onValueChange={(value) => setState(value as State)}
        size="small"
        value={state}
      >
        <ChoiceGroup.Item value="pending">Pending</ChoiceGroup.Item>
        <ChoiceGroup.Item value="sent">Sent</ChoiceGroup.Item>
        <ChoiceGroup.Item value="failed">Failed</ChoiceGroup.Item>
      </ChoiceGroup.Root>
      <Surface {...styles.card()}>
        <Frame.Root>
          {/* One live region holds the outcome, so each change is announced once. */}
          <div aria-live="polite" {...styles.result()}>
            {state === 'pending' && (
              <Spinner aria-label="Sending" size="large" {...styles.icon()} />
            )}
            {state === 'sent' && <CheckCircle aria-hidden="true" {...styles.positive()} />}
            {state === 'failed' && <AlertCircle aria-hidden="true" {...styles.negative()} />}
            <Frame.Title>{content.title}</Frame.Title>
            <Amount size="md" symbol="PathUSD" value="250.00" />
            <p {...styles.text()}>{content.description}</p>
          </div>
          {content.primary && (
            <Frame.Footer {...styles.footer()}>
              <Button size="large">{content.primary}</Button>
              <Button variant="text">{content.secondary}</Button>
            </Frame.Footer>
          )}
        </Frame.Root>
      </Surface>
    </Stack>
  )
}

namespace styles {
  export const card = style({
    boxSizing: 'border-box',
    marginInline: 'auto !custom',
    maxWidth: 'md',
    width: '100% !custom',
  })

  export const footer = style({ rowGap: '2' })

  export const icon = style({ marginBlockEnd: '2' })

  export const negative = style({ color: 'red.700', fontSize: '4xl', marginBlockEnd: '2' })

  export const positive = style({ color: 'green.700', fontSize: '4xl', marginBlockEnd: '2' })

  export const result = style({
    alignItems: 'center',
    display: 'flex',
    flexDirection: 'column',
    minHeight: '48',
    rowGap: '2',
    textAlign: 'center',
  })

  export const text = style({ color: 'gray.900', margin: '0', typography: 'copy.14' })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/PaymentsStatus.tsx)

* **Pending.** Keep the flow on screen with a `Spinner`, the amount, and the recipient. Say whether you can leave, such as You can close this screen.
* **Sent.** Show a check, the amount, and the recipient, then Done and View receipt actions. Confirm with a `Toast` when the payment finishes after you leave the flow.
* **Failed.** Say what happened and whether funds moved, such as Your balance hasn't changed. Offer Try again with the same details, and Edit payment.

Announce the outcome once through a polite live region. Don't rely on the icon's color, since the title states the result.

## Keep a Receipt

A receipt records the payment in full, so it shows values that the review step shortened.

```tsx
import { Amount, Button, Hex, Rows, style, Surface, Tag } from '@tempoxyz/ds'
import { ExternalLink } from '@tempoxyz/ds/icons'

export const fill = true
export function Demo() {
  return (
    <Surface render={<section aria-labelledby="receipt-title" />} {...styles.card()}>
      <Surface.Header>
        <Surface.Title id="receipt-title">Receipt</Surface.Title>
        <Tag intent="positive">
          <Tag.Dot />
          Confirmed
        </Tag>
      </Surface.Header>
      <Amount size="lg" symbol="PathUSD" value="250.00" />
      <div {...styles.rows()}>
        <Rows.Root dividers="full">
          <Rows.Row>
            <Rows.Label>From</Rows.Label>
            <Hex copy value="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" />
          </Rows.Row>
          <Rows.Row>
            <Rows.Label>To</Rows.Label>
            <Hex copy value="0x71C7656EC7ab88b098defB751B7401B5f6d8976F" />
          </Rows.Row>
          <Rows.Row>
            <Rows.Label>Network fee</Rows.Label>
            <Amount symbol="PathUSD" value="0.00003" />
          </Rows.Row>
          <Rows.Row>
            <Rows.Label>Total</Rows.Label>
            <Amount symbol="PathUSD" value="250.00003" />
          </Rows.Row>
          <Rows.Row>
            <Rows.Label>Date</Rows.Label>
            <time dateTime="2026-10-20T09:41:07Z">Oct 20, 2026, 09:41:07 UTC</time>
          </Rows.Row>
          <Rows.Row>
            <Rows.Label>Block</Rows.Label>
            <span {...styles.number()}>18,204,551</span>
          </Rows.Row>
          <Rows.Row>
            <Rows.Label>Transaction</Rows.Label>
            <Hex copy value="0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060" />
          </Rows.Row>
        </Rows.Root>
      </div>
      <div {...styles.actions()}>
        <Button.Link
          href="https://explore.tempo.xyz"
          rel="noreferrer"
          target="_blank"
          variant="secondary"
        >
          View on explorer
          <ExternalLink aria-hidden="true" />
        </Button.Link>
      </div>
    </Surface>
  )
}

namespace styles {
  export const actions = style({ display: 'flex', marginBlockStart: '4' })

  export const card = style({
    boxSizing: 'border-box',
    marginInline: 'auto !custom',
    maxWidth: 'md',
    width: '100% !custom',
  })

  export const number = style({ fontVariantNumeric: 'tabular-nums !custom' })

  // Surface pads 24px, so the rows reach its edges and keep their own 20px inset. A direct child
  // `Rows` would bleed on its own, but this one follows the amount, so a wrapper sets the margins.
  export const rows = style({ marginBlockStart: '4', marginInline: '-24px !custom' })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/PaymentsReceipt.tsx)

* Show the status with a `Tag`, such as Confirmed, and its text.
* List amount, fee, total, sender, recipient, date with seconds and time zone, block, and transaction hash.
* Give every address and hash a copy button through `Hex` with `copy`.
* Link to the block explorer with `Button.Link` and an `ExternalLink` icon. Say that it opens a new tab when it does.

## Receive and Deposit

Receiving shows your address two ways: a `Qr` for scanning and the address as text for copying.

```tsx
import { Callout, Hex, Qr, style, Surface } from '@tempoxyz/ds'

export const fill = true
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'

export function Demo() {
  return (
    <Surface render={<section aria-labelledby="receive-title" />} {...styles.card()}>
      <Surface.Header>
        <Surface.Title id="receive-title">Receive</Surface.Title>
      </Surface.Header>
      <div {...styles.body()}>
        <Qr aria-label="Wallet address QR code" size="medium" value={address} />
        {/* A filled Hex spans the card, so it truncates as little as possible and the whole row copies. */}
        <div {...styles.address()}>
          <Hex.Root size="large" value={address} variant="filled">
            <Hex.Text />
            <Hex.Copy aria-label="Copy address" />
          </Hex.Root>
        </div>
        <Callout intent="info" role="note" size="small">
          <Callout.Icon />
          <Callout.Content>Send stablecoins on Tempo to this address.</Callout.Content>
        </Callout>
      </div>
    </Surface>
  )
}

namespace styles {
  export const address = style({ alignSelf: 'stretch' })

  export const body = style({
    alignItems: 'center',
    display: 'flex',
    flexDirection: 'column',
    rowGap: '5',
  })

  export const card = style({
    boxSizing: 'border-box',
    marginInline: 'auto !custom',
    maxWidth: 'sm',
    width: '100% !custom',
  })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/PaymentsReceive.tsx)

* Name the code by purpose with `aria-label`, such as Wallet address QR code.
* Show the address in a `Hex` with `variant="filled"` across the full width, so as much of it shows as fits and the whole row copies.
* Name the copy button by what it copies. Compose `Hex.Root`, `Hex.Text`, and `Hex.Copy` with `aria-label="Copy address"`.
* Encode exactly the address you show, and state what it accepts, such as Send stablecoins on Tempo to this address.

**Do:** Pair the QR code with the address and a copy action.

**Don't:** Show a QR code alone, since many people can't scan it.

Add funds starts with the method. Use `Choicebox.RadioGroup` with a title and arrival time for each option, such as Bank transfer or Debit card. A crypto deposit then shows the receive layout above, with the minimum deposit and the accepted tokens.

## Content

* Use verbs that name the movement: Send, Receive, Add funds, Withdraw.
* Repeat the amount and token in every step, so a screen never says only Confirm.
* Write amounts with their token, such as 250.00 PathUSD, or with a currency symbol, such as $250.00.
* Say Network fee, not gas. Tempo charges fees in stablecoins, so state the fee in the token that pays it.
* Name failures by cause when you know it, such as Not enough PathUSD for the fee.

## Accessibility

* Label every field visibly. `Field` links `Field.Description` and a shown `Field.Error` to the control through `aria-describedby`.
* Move focus to the first invalid field after a failed submit, and to the new step's heading when the step changes.
* `Button` with `loading` sets `aria-busy`. Announce the outcome separately through a live region or a `Toast`.
* `Hex` gives screen readers the full address, even when the visible text is truncated. Name copy buttons by what they copy, such as Copy address.
* `CopyButton` announces Copied through a polite status region.
* Keep the confirm action 56px tall on the web and at least 44 points on touch screens.
* Don't rely on color for status. Each state has a title that says Sent or Failed.

## Related

* [AmountInput](/docs/components/amount-input) for entering amounts
* [Amount](/docs/components/amount) for formatting amounts
* [Rows](/docs/components/rows) for the review and receipt
* [Qr](/docs/components/qr) for receiving funds
* [Blockchain Values](/docs/patterns/blockchain-values) for addresses, hashes, and precision
* [Forms](/docs/patterns/forms) for labels and validation
* [Errors and Validation](/docs/patterns/errors-and-validation) for error messages
* [Destructive Actions](/docs/patterns/destructive-actions) for irreversible actions
* [Tempo Wallet](/docs/products/tempo-wallet) for the wallet's flows
