# Callout

A tinted message that stays on the page, next to the content that it is about.

```tsx
import { Button, Callout, toast } from 'ferry-ui'

export default function CalloutHero() {
  return (
    <Callout
      tone="warning"
      title="Your trial ends in 3 days"
      actionsPlacement="end"
      actions={
        <Button size="tiny" onClick={() => toast.success('Payment method added')}>
          Add payment method
        </Button>
      }
      className="w-full max-w-xl"
    >
      Add a payment method to keep access to your projects.
    </Callout>
  )
}
```

## Usage guidelines

- **A message that stays.** Use `Callout` for a message about a page, a card or a form. It stays until the situation changes.
- **A short-lived message is a toast.** To show the result of an action, use [Toast](/docs/components/toast).
- **A question is a dialog.** To ask for a confirmation, use [Confirm Dialog](/docs/components/confirm-dialog).
- **No data is not a message.** For an empty list or a first load with an error, use [Empty State](/docs/components/empty-state).
- **One field, one error.** Show the error of one field below the field, with [Field](/docs/components/field).

## Anatomy

Import the components. Each component has one part.

```tsx title="Anatomy"

<Callout title="" actions={<Button size="tiny" />}>
  {/* the message */}
</Callout>

<StaleDataCallout error={error} onRetry={retry} />
```

## Examples

### Tones

The `tone` prop sets the color and the default icon. The `neutral` tone has no icon.

```tsx
import { Callout } from 'ferry-ui'

export default function CalloutTones() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <Callout tone="info" title="Two-factor authentication is off">
        Members can sign in with a password only.
      </Callout>
      <Callout tone="warning" title="Your trial ends in 3 days">
        Add a payment method to keep access to your projects.
      </Callout>
      <Callout tone="destructive" title="The last payment failed">
        The bank declined the card. Update the payment method.
      </Callout>
      <Callout tone="success" title="Your domain is verified">
        Emails from your domain now have a signature.
      </Callout>
      <Callout tone="neutral" title="Invoices go out each month">
        The billing email gets them on the first day of the month.
      </Callout>
    </div>
  )
}
```

| Tone | Use |
| --- | --- |
| `info` (default) | Guidance, a feature that is off, work in progress. |
| `warning` | A situation that needs attention. There is no failure yet. |
| `destructive` | A failure. Screen readers read it immediately. |
| `success` | A good result that must stay on the page. |
| `neutral` | A side note. |

### Sizes

The `md` size is for pages and cards. The `sm` size is for forms, dialogs and dense panels.

```tsx
import { Callout } from 'ferry-ui'

export default function CalloutSizes() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <Callout tone="warning" title="This invoice is overdue">
        The customer gets a reminder every 7 days.
      </Callout>
      <Callout tone="warning" size="sm" title="This invoice is overdue">
        The customer gets a reminder every 7 days.
      </Callout>
      <Callout tone="warning" size="sm">
        This invoice is overdue.
      </Callout>
    </div>
  )
}
```

### Submit error

If a submit fails, show a `destructive` callout with `size="sm"` near the submit button. Click Save to see the error.

```tsx
import * as React from 'react'
import { Button, Callout, Field, Input } from 'ferry-ui'

const TAKEN_SLUGS = ['billing-portal', 'docs']

export default function CalloutSubmitError() {
  const [slug, setSlug] = React.useState('billing-portal')
  const [error, setError] = React.useState<string>()

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    // The server refuses a slug that another project uses.
    setError(TAKEN_SLUGS.includes(slug.trim()) ? `The slug "${slug.trim()}" is not available.` : undefined)
  }

  return (
    <form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4">
      <Field label="Project slug">
        <Input mono value={slug} onChange={(event) => setSlug(event.target.value)} />
      </Field>
      {error && (
        <Callout tone="destructive" size="sm">
          {error}
        </Callout>
      )}
      <Button type="submit" variant="primary" className="self-end">
        Save
      </Button>
    </form>
  )
}
```

### Banner

With `variant="banner"`, the callout is a full-width strip. Put it at the top of a card or a panel. The banner ignores `size`.

```tsx
import { Callout, Card, CardAction, CardHeader, CardTitle, StatusBadge } from 'ferry-ui'

const EVENTS = [
  { who: 'Maya Chen', what: 'approved invoice INV-2041', when: '2 min ago' },
  { who: 'Liam Novak', what: 'invited a member to Billing', when: '14 min ago' },
  { who: 'Ines Duarte', what: 'created an API key', when: '1 h ago' },
]

export default function CalloutBanner() {
  return (
    <Card className="w-full max-w-xl">
      <CardHeader>
        <CardTitle>Activity</CardTitle>
        <CardAction>
          <StatusBadge tone="neutral" label="Paused" size="sm" />
        </CardAction>
      </CardHeader>
      <Callout variant="banner" tone="destructive">
        The live feed has no connection. New events do not show.
      </Callout>
      <ul className="divide-y text-[13px]">
        {EVENTS.map((event) => (
          <li key={event.what} className="flex items-center justify-between gap-4 px-5 py-2.5 md:px-6">
            <span className="min-w-0 truncate text-foreground-light">
              <span className="font-medium text-foreground">{event.who}</span> {event.what}
            </span>
            <span className="shrink-0 text-foreground-lighter">{event.when}</span>
          </li>
        ))}
      </ul>
    </Card>
  )
}
```

### Actions

Give small buttons to `actions`. They show below the text.

For one short action, set `actionsPlacement="end"`. The button then shows on the right of the text. On a phone, it stays below the text.

```tsx
import { Button, Callout, toast } from 'ferry-ui'
import { RefreshCw } from 'lucide-react'

export default function CalloutActions() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <Callout
        tone="warning"
        title="A member changed this document"
        actions={
          <>
            <Button size="tiny" onClick={() => toast.success('Document loaded again')}>
              Discard my edits
            </Button>
            <Button size="tiny" variant="ghost" onClick={() => toast.info('Two versions to compare')}>
              Compare versions
            </Button>
          </>
        }
      >
        If you save now, your version replaces their version.
      </Callout>
      <Callout
        tone="destructive"
        title="The export failed"
        actionsPlacement="end"
        actions={
          <Button size="tiny" icon={<RefreshCw />} onClick={() => toast.success('Export started')}>
            Retry
          </Button>
        }
      >
        The billing service did not answer in time.
      </Callout>
    </div>
  )
}
```

### Icon

Pass a lucide icon to `icon` to replace the default icon. Pass `false` to hide the icon.

```tsx
import { Callout } from 'ferry-ui'
import { ShieldCheck } from 'lucide-react'

export default function CalloutIcon() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <Callout tone="success" icon={<ShieldCheck />} title="Single sign-on is on">
        Members sign in through your identity provider.
      </Callout>
      <Callout tone="info" icon={false}>
        Invoices use the currency of the project.
      </Callout>
    </div>
  )
}
```

### Stale data

A refresh can fail while the page still shows old data. Keep the data. Put `StaleDataCallout` above it. The callout shows the message of `error` and a Retry button.

```tsx
import * as React from 'react'
import { StaleDataCallout } from 'ferry-ui'

const ORDERS = [
  { id: 'ORD-1042', customer: 'Northwind Traders', total: '$1,250.00' },
  { id: 'ORD-1043', customer: 'Acme', total: '$348.00' },
  { id: 'ORD-1044', customer: 'Globex', total: '$92.50' },
]

export default function CalloutStaleData() {
  const [retrying, setRetrying] = React.useState(false)

  function retry() {
    setRetrying(true)
    window.setTimeout(() => setRetrying(false), 1500)
  }

  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <StaleDataCallout error={new Error('The orders service did not answer in time.')} onRetry={retry} retrying={retrying} />
      <ul className="divide-y rounded-lg border bg-surface-100 text-[13px]">
        {ORDERS.map((order) => (
          <li key={order.id} className="flex items-center justify-between gap-4 px-4 py-2.5">
            <span className="text-foreground">
              <span className="font-mono">{order.id}</span> · {order.customer}
            </span>
            <span className="text-foreground-light tabular">{order.total}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}
```

## Accessibility

- A `destructive` callout has the `alert` role. The other tones have the `note` role.
- Use `role` to change the role. For example, `status` gives a polite live update.
- The icon is decorative. Write the meaning in the text.

## API reference

`Callout` also accepts each attribute of the `<div>` element. `StaleDataCallout` passes its other props to `Callout`.

### Callout

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tone` | `"destructive" \| "warning" \| "success" \| "info" \| "neutral"` | `info` | Colour, default icon and default ARIA role. Defaults to `info`. See `CalloutTone`. |
| `size` | `"sm" \| "md"` | `md` | `md` (default) for page sections and cards, `sm` for compact errors in forms and dialogs. Ignored by `variant="banner"`. See `CalloutSize`. |
| `variant` | `"banner" \| "box"` | `box` | `box` (default) in the content flow, `banner` flush at the top of a card or panel. See `CalloutVariant`. |
| `icon` | `ReactNode` |  | 16px icon on the left, tinted with the tone colour. Omitted (or `true`), it is the tone's icon (info: Info, warning / destructive: TriangleAlert, success: CircleCheck, neutral: none). Pass another Lucide icon to replace it, or `false` (or `null`) to hide it, e.g. when the callout wraps a checkbox. |
| `title` | `ReactNode` |  | Bold first line. Optional: a callout can be a single sentence of body text. |
| `children` | `ReactNode` |  | The message. Can hold paragraphs, lists, inline code or form controls; long unbroken tokens (ids, URLs) wrap. It is drawn in the lighter foreground (`md`, or `sm` with a title), the main foreground (`sm` without a title) or the tone colour (`banner`). |
| `actions` | `ReactNode` |  | Small buttons (`size="tiny"`), e.g. "Retry", "Discard my edits", "Learn more". Placed by `actionsPlacement`. |
| `actionsPlacement` | `"bottom" \| "end"` | `bottom` | `bottom` (default) under the text, or `end` on the right of it. See `CalloutActionsPlacement`. |
| `role` | `AriaRole` |  | ARIA role of the root. Defaults to `alert` for `destructive` (read out as soon as it appears) and `note` for the other tones. Override it, e.g. with `status` for a polite live update. |

### StaleDataCallout

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `error` (required) | `unknown` |  | What the failed refresh threw or rejected with; its message is appended to the sentence through `getErrorMessage`. |
| `onRetry` (required) | `() => void` |  | Called by the Retry button. |
| `size` | `"sm" \| "md"` |  | `md` (default) for page sections and cards, `sm` for compact errors in forms and dialogs. Ignored by `variant="banner"`. See `CalloutSize`. |
| `variant` | `"banner" \| "box"` |  | `box` (default) in the content flow, `banner` flush at the top of a card or panel. See `CalloutVariant`. |
| `title` | `ReactNode` |  | Bold first line. Optional: a callout can be a single sentence of body text. |
| `actionsPlacement` | `"bottom" \| "end"` |  | `bottom` (default) under the text, or `end` on the right of it. See `CalloutActionsPlacement`. |
| `role` | `AriaRole` |  | ARIA role of the root. Defaults to `alert` for `destructive` (read out as soon as it appears) and `note` for the other tones. Override it, e.g. with `status` for a polite live update. |
| `retrying` | `boolean` |  | Spinner on the Retry button (disabled) while the new attempt runs. |
| `retryLabel` | `ReactNode` | `Retry` | Label of the Retry button. Defaults to "Retry". |
| `children` | `ReactNode` |  | Replaces the default sentence (`This data may be out of date. Refreshing failed: <message>`). |
