Patterns
Callout
A tinted message that stays on the page, next to the content that it is about.
Patterns
A tinted message that stays on the page, next to the content that it is about.
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>
)
}Callout for a message about a page, a card or a form. It stays until the situation changes.Import the components. Each component has one part.
import { Button, Callout, StaleDataCallout } from 'ferry-ui'
<Callout title="" actions={<Button size="tiny" />}>
{/* the message */}
</Callout>
<StaleDataCallout error={error} onRetry={retry} />The tone prop sets the color and the default icon. The neutral tone has no icon.
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. |
The md size is for pages and cards. The sm size is for forms, dialogs and dense panels.
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>
)
}If a submit fails, show a destructive callout with size="sm" near the submit button. Click Save to see the error.
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>
)
}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.
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>
)
}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.
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>
)
}Pass a lucide icon to icon to replace the default icon. Pass false to hide the icon.
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>
)
}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.
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>
)
}destructive callout has the alert role. The other tones have the note role.role to change the role. For example, status gives a polite live update.Callout also accepts each attribute of the <div> element. StaleDataCallout passes its other props to Callout.
| Prop | Type | Default |
|---|---|---|
tone | "destructive" | "warning" | "success" | "info" | "neutral" | info |
Colour, default icon and default ARIA role. Defaults to | ||
size | "sm" | "md" | md |
| ||
variant | "banner" | "box" | box |
| ||
icon | ReactNode | - |
16px icon on the left, tinted with the tone colour. Omitted (or | ||
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 ( | ||
actions | ReactNode | - |
Small buttons ( | ||
actionsPlacement | "bottom" | "end" | bottom |
| ||
role | AriaRole | - |
ARIA role of the root. Defaults to | ||
| Prop | Type | Default |
|---|---|---|
errorRequired | unknown | - |
What the failed refresh threw or rejected with; its message is appended to the sentence through | ||
onRetryRequired | () => void | - |
Called by the Retry button. | ||
size | "sm" | "md" | - |
| ||
variant | "banner" | "box" | - |
| ||
title | ReactNode | - |
Bold first line. Optional: a callout can be a single sentence of body text. | ||
actionsPlacement | "bottom" | "end" | - |
| ||
role | AriaRole | - |
ARIA role of the root. Defaults to | ||
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 ( | ||