Patterns
Empty State
A placeholder for an area that has no content to show, and a box for a load that failed.
Patterns
A placeholder for an area that has no content to show, and a box for a load that failed.
No invoices yet
import { Button, EmptyState, toast } from 'ferry-ui'
import { Plus, Receipt } from 'lucide-react'
export default function EmptyStateHero() {
return (
<EmptyState
icon={<Receipt />}
title="No invoices yet"
description="The invoices that you send to your customers show here."
actions={
<Button variant="primary" icon={<Plus />} onClick={() => toast.success('Invoice created')}>
New invoice
</Button>
}
/>
)
}EmptyState for an empty list, a first run and a search with no result. Say what goes there. Give the button that adds it.ErrorState with onRetry.StaleDataCallout above it. See Callout.Import the components. Each component has one part.
import { Button, EmptyState, ErrorState } from 'ferry-ui'
<EmptyState icon={<Icon />} title="" description="" actions={<Button />} />
<ErrorState title="" error={error} onRetry={retry} />The variant prop sets the frame.
No projects yet
Page not found
No comments yet
import { EmptyState } from 'ferry-ui'
import { FileQuestion, FolderKanban, MessageSquare } from 'lucide-react'
export default function EmptyStateVariants() {
return (
<div className="grid gap-4 sm:grid-cols-3">
<EmptyState
variant="dashed"
size="sm"
icon={<FolderKanban />}
title="No projects yet"
description="The dashed variant."
/>
<EmptyState
variant="bordered"
size="sm"
icon={<FileQuestion />}
title="Page not found"
description="The bordered variant."
/>
<EmptyState
variant="plain"
size="sm"
icon={<MessageSquare />}
title="No comments yet"
description="The plain variant."
/>
</div>
)
}| Variant | Frame | Use |
|---|---|---|
dashed (default) | A dashed border. | An empty list, a drop zone. |
bordered | A card with a shadow. | A full page: not found, crashed, no access. |
plain | No frame. | In a card, a panel or a popover. |
The size prop sets the vertical padding.
No members yet
No members yet
No members yet
import { EmptyState } from 'ferry-ui'
import { Users } from 'lucide-react'
export default function EmptyStateSizes() {
return (
<div className="grid items-start gap-4 sm:grid-cols-3">
<EmptyState size="sm" icon={<Users />} title="No members yet" description="Small, 24px of padding." />
<EmptyState size="md" icon={<Users />} title="No members yet" description="Medium, 40px of padding." />
<EmptyState size="lg" icon={<Users />} title="No members yet" description="Large, 64px of padding." />
</div>
)
}| Size | Padding | Use |
|---|---|---|
sm | 24px | Cards and side panels. |
md (default) | 40px | Sections of a page. |
lg | 64px | A full page, a first run. |
If a search or a filter has no result, say what the search was. Give a button that clears it.
No results for "payroll"
import * as React from 'react'
import { Button, EmptyState, SearchInput } from 'ferry-ui'
import { SearchX } from 'lucide-react'
const CUSTOMERS = ['Acme', 'Globex', 'Northwind Traders']
export default function EmptyStateNoResults() {
const [query, setQuery] = React.useState('payroll')
const matches = CUSTOMERS.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()))
return (
<div className="mx-auto flex w-full max-w-md flex-col gap-4">
<SearchInput placeholder="Search customers" value={query} onValueChange={setQuery} className="w-full" />
{matches.length === 0 ? (
<EmptyState
icon={<SearchX />}
title={`No results for "${query}"`}
description="Check the spelling or search for another name."
actions={<Button onClick={() => setQuery('')}>Clear search</Button>}
/>
) : (
<ul className="divide-y rounded-lg border bg-surface-100 text-sm text-foreground">
{matches.map((name) => (
<li key={name} className="px-4 py-2.5">
{name}
</li>
))}
</ul>
)}
</div>
)
}The card is the frame. Use the plain variant and the sm size.
No activity yet
import { Card, CardContent, CardHeader, CardTitle, EmptyState } from 'ferry-ui'
import { History } from 'lucide-react'
export default function EmptyStateInCard() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Recent activity</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
variant="plain"
size="sm"
icon={<History />}
title="No activity yet"
description="Edits, comments and invitations show here."
/>
</CardContent>
</Card>
)
}For a page that has no content at all, use the bordered variant and the lg size. Give a button that opens another page.
Page not found
import { Button, EmptyState, toast } from 'ferry-ui'
import { FileQuestion } from 'lucide-react'
export default function EmptyStateFullPage() {
return (
<EmptyState
variant="bordered"
size="lg"
icon={<FileQuestion />}
title="Page not found"
description="This page has a new address, or it does not exist."
actions={
<>
<Button onClick={() => toast.info('Back to the last page')}>Go back</Button>
<Button variant="primary" onClick={() => toast.info('Dashboard opened')}>
Go to the dashboard
</Button>
</>
}
/>
)
}The children show below the actions. Use them for a code sample or a help link.
No API keys yet
Or use the command line:
$ acme keys createimport { Button, CodeBlock, EmptyState, toast } from 'ferry-ui'
import { KeyRound, Plus } from 'lucide-react'
export default function EmptyStateChildren() {
return (
<EmptyState
icon={<KeyRound />}
title="No API keys yet"
description="Your server uses an API key to call the API."
actions={
<Button variant="primary" icon={<Plus />} onClick={() => toast.success('API key created')}>
New API key
</Button>
}
>
<div className="mt-2 flex w-full max-w-sm flex-col gap-2 text-left">
<p className="text-[13px] text-foreground-lighter">Or use the command line:</p>
<CodeBlock prompt code="acme keys create" what="command" />
</div>
</EmptyState>
)
}ErrorState shows a title and the message of error. With onRetry, it also shows a Retry button. Set retrying while the new load is in progress.
Could not load invoices
import * as React from 'react'
import { ErrorState } from 'ferry-ui'
export default function ErrorStateRetry() {
const [retrying, setRetrying] = React.useState(false)
function retry() {
setRetrying(true)
window.setTimeout(() => setRetrying(false), 1500)
}
return (
<ErrorState
title="Could not load invoices"
error={new Error('The request timed out after 30 seconds.')}
onRetry={retry}
retrying={retrying}
/>
)
}If a retry cannot help, do not pass onRetry. The button does not show. Use description to replace the message of error.
You do not have access to billing
import { ErrorState } from 'ferry-ui'
export default function ErrorStateDescription() {
return (
<ErrorState
title="You do not have access to billing"
error={new Error('403 billing_admin_required')}
description="Only a billing admin can see the invoices. Ask the owner of the workspace for this role."
/>
)
}ErrorState has the alert role. Screen readers read it when it shows.EmptyState is decorative. Write the meaning in the title.The two components also accept each attribute of the <div> element.
| Prop | Type | Default |
|---|---|---|
titleRequired | ReactNode | - |
One short sentence saying what is missing ("No invoices yet", "No results for “acme”"). | ||
icon | ReactNode | - |
Lucide icon (or any 18px glyph) above the title, drawn in the lighter foreground. Tint it with a class ( | ||
description | ReactNode | - |
What the area is for and how to fill it, in one or two sentences. | ||
actions | ReactNode | - |
Call-to-action buttons, centered under the text. Usually one | ||
variant | "dashed" | "bordered" | "plain" | dashed |
Frame of the block:
- | ||
size | "sm" | "md" | "lg" | md |
Vertical padding: | ||
children | ReactNode | - |
Extra content under the actions: a code snippet, a help link, a small illustration. | ||
| Prop | Type | Default |
|---|---|---|
error | unknown | - |
What was thrown or rejected (an | ||
title | ReactNode | Something went wrong |
Headline naming what failed ("Could not load invoices"). Defaults to "Something went wrong". | ||
description | ReactNode | - |
Custom message replacing the one read from | ||
onRetry | (() => void) | - |
Shows a Retry button that calls it. Omit it when retrying cannot help (e.g. a permission error). | ||
retrying | boolean | - |
Spinner on the Retry button while the new attempt runs. | ||
retryLabel | ReactNode | Retry |
Label of the Retry button. Defaults to "Retry". | ||