# Confirm send

> Click, confirm with the recipient count, send, then a status line. Can be blocked with a reason.

```tsx
"use client"

import * as React from "react"

import { ConfirmSend } from "@/components/ui/confirm-send"
import { Switch } from "@/components/ui/switch"

// Fictional data. Nothing is sent anywhere.
export function ConfirmSendDemo() {
  const [sending, setSending] = React.useState(false)
  const [sentCount, setSentCount] = React.useState<number | null>(null)
  const [unsaved, setUnsaved] = React.useState(false)
  const count = 3

  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <div className="flex flex-wrap items-center gap-3">
        <ConfirmSend
          count={count}
          label="Email this summary"
          sending={sending}
          sentCount={sentCount}
          blockedReason={
            unsaved ? "Save your changes before sending" : undefined
          }
          onSend={async () => {
            setSending(true)
            await new Promise((r) => setTimeout(r, 600))
            setSending(false)
            setSentCount(count)
          }}
        />
      </div>
      <label className="flex items-center gap-2 text-sm text-muted-foreground">
        <Switch checked={unsaved} onCheckedChange={setUnsaved} />
        Pretend there are unsaved edits
      </label>
    </div>
  )
}
```

Use it for any action that messages other people and cannot be taken back. It is never one click to send: the button becomes "Send to 3 people?" with Send and Cancel, and only then does your handler run. The count is part of the question, so nobody sends to a list they have not seen.

It is usually the `action` of a [`status-strip`](https://realgood.site/docs/components/status-strip.md).

## Installation

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/confirm-send.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/confirm-send
```

This also adds the shadcn `button` and `lucide-react`.

**Manual**

**Step 1.** Copy and paste the following code into your project.

```tsx title="components/ui/confirm-send.tsx"
"use client"

import * as React from "react"
import { CheckCircle2 } from "lucide-react"

import { Button } from "@/components/ui/button"

export interface ConfirmSendProps {
  /** How many recipients the SAVED list reaches. */
  count: number
  /** One recipient, e.g. "person". */
  noun?: string
  /** Plural of `noun`, e.g. "people". Defaults to `noun + "s"`. */
  nounPlural?: string
  /** Blocks the send and explains why (e.g. unsaved edits). Cancels an open confirmation. */
  blockedReason?: string
  /** Shown as the reason when `count` is 0. */
  emptyReason?: string
  /** Disables the button while your send runs. */
  sending?: boolean
  /** Set after a successful send to show "Sent to N". */
  sentCount?: number | null
  /** Label for the idle button. Defaults to "Send now". */
  label?: string
  /** Called after the person confirms. */
  onSend: () => void | Promise<void>
}

/** Click, confirm with the count, send, then a status line. Never one click to send. */
function ConfirmSend({
  count,
  noun = "person",
  nounPlural,
  blockedReason,
  emptyReason = "Add at least one recipient first",
  sending = false,
  sentCount = null,
  label = "Send now",
  onSend,
}: ConfirmSendProps) {
  const [confirming, setConfirming] = React.useState(false)
  const reasonId = React.useId()
  const promptRef = React.useRef<HTMLSpanElement>(null)
  const idleRef = React.useRef<HTMLButtonElement>(null)
  // Set when the user leaves the confirmation, so focus can go back to the button.
  const restoreFocus = React.useRef(false)
  const plural = nounPlural ?? (noun === "person" ? "people" : `${noun}s`)
  const phrase = (n: number) => `${n} ${n === 1 ? noun : plural}`
  const reason = blockedReason ?? (count === 0 ? emptyReason : undefined)

  // The list changed under an open prompt (an edit, a save): what it says is no
  // longer what would be sent, so drop back to the idle button. Done while
  // rendering, not in an effect, so the stale prompt is never painted.
  if (reason && confirming) setConfirming(false)

  // The button that had focus is replaced by the prompt (and back), so move
  // focus with it. Waits while the idle button is disabled (sending) and never
  // steals focus the user has already moved elsewhere.
  React.useEffect(() => {
    if (confirming) {
      promptRef.current?.focus()
    } else if (restoreFocus.current && idleRef.current && !sending) {
      restoreFocus.current = false
      if (document.activeElement === document.body) idleRef.current.focus()
    }
  }, [confirming, sending])

  const justSent = sentCount !== null && !reason
  // One live region that stays mounted across both states, so each send is
  // announced; it is emptied while a send is in flight so a repeat is announced.
  const liveMessage =
    confirming || !justSent || sending
      ? sending
        ? "Sending…"
        : ""
      : `Sent to ${phrase(sentCount)}`

  return (
    <>
      <span role="status" className="sr-only">
        {liveMessage}
      </span>
      {confirming && !reason ? (
        <>
          {/* tabIndex -1: focusable by script so the question is read out, but not a tab stop. */}
          <span
            ref={promptRef}
            tabIndex={-1}
            className="text-sm text-muted-foreground outline-none"
          >
            Send to {phrase(count)}?
          </span>
          <Button
            disabled={sending}
            onClick={async () => {
              restoreFocus.current = true
              setConfirming(false)
              await onSend()
            }}
          >
            {sending ? "Sending…" : "Send"}
          </Button>
          <Button
            variant="outline"
            onClick={() => {
              restoreFocus.current = true
              setConfirming(false)
            }}
          >
            Cancel
          </Button>
        </>
      ) : (
        <>
          {justSent && (
            <span
              aria-hidden="true"
              className="inline-flex items-center gap-1.5 text-sm font-medium"
            >
              <CheckCircle2 className="size-4" />
              Sent to {phrase(sentCount)}
            </span>
          )}
          <Button
            ref={idleRef}
            variant={justSent ? "outline" : "default"}
            disabled={Boolean(reason) || sending}
            title={reason}
            aria-describedby={reason ? reasonId : undefined}
            onClick={() => setConfirming(true)}
          >
            {count === 0 ? label : `${label} to ${phrase(count)}`}
          </Button>
          {reason && (
            <span id={reasonId} className="sr-only">
              {reason}
            </span>
          )}
        </>
      )}
    </>
  )
}

export { ConfirmSend }
```

**Step 2.** Add the shadcn `button` and install `lucide-react`. Update the import paths to
match your project setup.

## Usage

```tsx
import { ConfirmSend } from "@/components/ui/confirm-send"
```

```tsx
const [sending, setSending] = React.useState(false)
const [sentCount, setSentCount] = React.useState<number | null>(null)

<ConfirmSend
  count={saved.length}
  label="Email this summary"
  sending={sending}
  sentCount={sentCount}
  blockedReason={dirty ? "Save your changes first" : undefined}
  onSend={async () => {
    setSending(true)
    try {
      setSentCount(await api.sendSummary())
    } finally {
      setSending(false)
    }
  }}
/>
```

The component owns only the "are you sure" step. You own `sending` and `sentCount`.

## Notes

- **Count the saved list.** Pass the number of people the send will actually reach. If the list on screen has unsaved edits, use `blockedReason` to stop the send until they are saved. A confirmation that is open when the list changes is dismissed, because what it says no longer matches what would be sent.
- **Blocked and empty.** `blockedReason` disables the button and sets it as the button's tooltip and its accessible description. When `count` is 0 the same happens with `emptyReason`. `blockedReason` wins when both apply.
- **"Sent to N".** Show it by passing `sentCount`. It appears beside the button, which becomes an outline button, and it hides while the button is blocked. Reset it to `null` when the list changes or you want it gone. The component never clears it.
- **Button text.** With a count it reads "`label` to 3 people" and with none just "`label`". Change the nouns with `noun` and `nounPlural`.
- **Sending state.** The confirmation closes as soon as Send is pressed, then `sending` disables the button. The label does not change while `sending` is true, so pair it with your own indicator if the send is slow.
- **Fragment output.** It renders a fragment of buttons and text, so put it in a flex container that supplies the gap.

## What it does not do

- **It does not send anything.** Your `onSend` does. The component owns only the "are you sure" step.
- **It does not know the list.** Pass the number of people the send will actually reach. If the screen has unsaved edits, block the send with `blockedReason`.
- **It does not retry, queue or track delivery.** You own `sending` and `sentCount`, and the message for a failure.
- **It is not access control.** A disabled button stops no one. Your server decides who may send.
- **It makes no compliance claim.** Confirming with a count is a UI cue, not compliance.

## Props

### ConfirmSend

The prop types are exported as `ConfirmSendProps`. It renders no wrapper element, so it takes no `className` or `ref`.

| Prop            | Type                          | Description                                                                       |
| --------------- | ----------------------------- | --------------------------------------------------------------------------------- |
| `count`         | `number`                      | How many recipients the saved list reaches. Required.                             |
| `onSend`        | `() => void \| Promise<void>` | Called after the person confirms. Required.                                       |
| `noun`          | `string`                      | One recipient. Default `"person"`.                                                |
| `nounPlural`    | `string`                      | Plural of `noun`. Default `"people"` for `"person"`, otherwise `noun` plus `"s"`. |
| `blockedReason` | `string`                      | Blocks the send and explains why. Cancels an open confirmation.                   |
| `emptyReason`   | `string`                      | The reason shown when `count` is 0. Default "Add at least one recipient first".   |
| `sending`       | `boolean`                     | Disables the button while your send runs. Default `false`.                        |
| `sentCount`     | `number \| null`              | Shows "Sent to N" when set. Default `null`.                                       |
| `label`         | `string`                      | Idle button label. Default "Send now".                                            |

## Agent prompt

Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project. Replace the bracketed parts with your own.

```text
Goal: put a confirm-then-send action on my [screen] for [what is sent, e.g. the weekly summary], so nothing goes out on one click and the person sees how many people it reaches first.

Install: npx shadcn@latest add https://realgood.site/r/confirm-send.json
Read the installed files (components/ui/confirm-send.tsx) before writing any code. Do not guess props.

Contract:
<ConfirmSend count={number} onSend={() => void | Promise<void>} noun?="person" nounPlural?="people" blockedReason?={string} emptyReason?={string} sending?={boolean} sentCount?={number | null} label?="Send now" />
It renders a fragment of buttons and text and takes no className, so put it in a flex container with a gap.

Wiring rule: the component owns only the "are you sure" step. I own `sending` and `sentCount`. `count` is the SAVED list the send will reach, not what is on screen. If the list has unsaved edits, pass blockedReason ("Save your changes first"). onSend calls my own API, sets `sending` in a try/finally, and on success sets `sentCount` to the number my API says it reached. Reset sentCount to null when the list changes. Do not send from the browser straight to a mail service.

States to handle: idle, confirming ("Send to 3 people?"), sending, sent ("Sent to 3"), failed (my own error text, and the button is usable again), blocked (reason shown as tooltip and description), empty list (count 0).

Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. One click never calls onSend. Send in the confirmation calls it once.
3. With unsaved edits the button is disabled and says why.
4. A failed send shows an error, does not show "Sent to N", and can be retried.
5. Keyboard only: Tab reaches Send and Cancel, and the blocked reason is read out.

UI only: it does not send, store, queue or check permissions, and it makes no compliance claim. No new dependencies or abstractions beyond this. If something is unclear, ask me.
```

## Examples by sector

Example data only, to show the wording. These are not claims about any real organisation, and the component proves nothing about a sector's rules. The pattern is the same in each; only the data changes.

- **Education.** "Send to 12 staff?" before a reminder goes to everyone who has not finished training.
- **Manufacturing.** "Send to 4 owners?" before a recall notice goes to the owners of overdue gauges.
- **Engineering.** "Send to 5 people?" before a change order's release notice goes to those who build from the drawing.
- **Health.** "Send to 3 staff?" before a licence-expiry reminder goes out. The count is of people, and the message carries no patient details.

## Next

Comes after: [`data-table`](https://realgood.site/docs/components/data-table.md), [`approval-step`](https://realgood.site/docs/components/approval-step.md), [`save-bar`](https://realgood.site/docs/components/save-bar.md). Leads to: [`audit-timeline`](https://realgood.site/docs/components/audit-timeline.md).
