Empty state
A titled placeholder for a region with nothing in it yet, with room for two actions.
Server-compatible: no hooks, no client boundary. Built on the upstream empty primitives, so it inherits their spacing and text balancing; its title keeps the face's own spacing, where the upstream title tightens it. Extra children render below the actions, which is how ErrorState hangs its details panel off it — or a list of setup steps, or the faces of the people an invite would reach. The HTML title attribute is replaced by the title prop. Say what is missing as a fact and offer the specific next step: clear this search, connect this account, ask this person — never a bare "No data". An action that goes somewhere is a link wearing buttonVariants.
Install
npx shadcn@latest add @vibra/empty-stateNeeds the @vibra registry in your components.json — set it up once.
Examples
import { BellPlusIcon, RadarIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
export default function EmptyStateDemo() {
return (
<EmptyState
className="w-full max-w-md"
icon={<RadarIcon />}
title="No monitors yet"
description="A monitor watches one metric and raises an alert when it drifts. The first one takes about a minute to set up."
action={
<Button>
<BellPlusIcon aria-hidden="true" />
Create a monitor
</Button>
}
secondaryAction={<Button variant="ghost">Import from a file</Button>}
/>
)
}Dashed
The dashed frame at both sizes, for a slot waiting to be filled.
import { FilterIcon, PlusIcon, TableIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
export default function EmptyStateDashed() {
return (
<div className="flex w-full max-w-md flex-col gap-4">
<EmptyState
variant="dashed"
icon={<TableIcon />}
title="Drop a panel here"
description="Anything you pin from a query lands in this row."
action={
<Button variant="outline" size="sm">
<PlusIcon aria-hidden="true" />
Add panel
</Button>
}
/>
<EmptyState
variant="dashed"
size="sm"
icon={<FilterIcon />}
title="No saved filters"
description="Save a filter set to reuse it across dashboards."
action={
<Button variant="outline" size="sm">
Save the current filters
</Button>
}
/>
</div>
)
}No results for a search
Repeats the search, says what it reads, and offers to clear it or to invite the person who is not here yet.
"use client"
import * as React from "react"
import { SearchXIcon, UserPlusIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
import { Input } from "@/components/ui/input"
const TEAM = [
["Sonia Keller", "Owner"],
["Aisha Gallo", "Admin"],
["Kwame Halvorsen", "Admin"],
["Hassan Ortiz", "Member"],
["Iris Brandt", "Member"],
]
// A search that finds no one says what it looked for, what it looks through,
// and the two ways on: clear it, or invite the person who is not here yet.
export default function EmptyStateSearch() {
const [query, setQuery] = React.useState("Kowalski")
const found = TEAM.filter(([name]) => name.toLowerCase().includes(query.trim().toLowerCase()))
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Input type="search" aria-label="Search teammates" value={query} onChange={(event) => setQuery(event.target.value)} />
{found.length === 0 ? (
<EmptyState
size="sm"
icon={<SearchXIcon />}
title={`No teammate matches “${query.trim()}”`}
description="Search reads names only. Check the spelling, or invite them if they are not in Northwind yet."
action={
<Button size="sm" variant="outline" onClick={() => setQuery("")}>
Clear the search
</Button>
}
secondaryAction={
<Button size="sm" variant="ghost">
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
Invite someone
</Button>
}
/>
) : (
<ul aria-label="Teammates" className="flex flex-col gap-2">
{found.map(([name, role]) => (
<li key={name} className="flex items-center gap-3 text-sm">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
<span className="flex-1">{name}</span>
<span className="text-muted-foreground">{role}</span>
</li>
))}
</ul>
)}
</div>
)
}First run, with the steps
Where setting up starts: the steps as an ordered list, the finished one said in words, and one button for the next.
import { CheckIcon, LandmarkIcon, ReceiptIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
const STEPS = [
{ title: "Add your business details", done: true },
{ title: "Connect the account payouts go to", done: false },
{ title: "Send your first invoice", done: false },
]
// Nothing has ever been here, so the empty state is where setting up starts:
// what the page will hold, the steps that fill it with the finished one ticked,
// and one button for the next step — not a generic "Get started".
export default function EmptyStateFirstRun() {
return (
<EmptyState
className="w-full max-w-md"
icon={<ReceiptIcon />}
title="No invoices yet"
description="Invoices you send land here with their status, due date and payment. Two steps are left before the first one."
action={
<Button>
<LandmarkIcon data-icon="inline-start" aria-hidden="true" />
Connect a payout account
</Button>
}
>
<ol aria-label="Setting up invoicing" className="flex w-full max-w-xs flex-col gap-2 text-start text-sm">
{STEPS.map((step, index) => (
<li key={step.title} className="flex items-center gap-3">
<span
className={cn(
"grid size-6 shrink-0 place-items-center rounded-full text-xs font-medium tabular-nums",
step.done ? "bg-foreground text-background" : "ring-1 ring-border text-muted-foreground"
)}
>
{step.done ? <CheckIcon aria-hidden="true" className="size-3.5" /> : index + 1}
</span>
<span className={cn(step.done && "text-muted-foreground line-through")}>{step.title}</span>
{step.done ? <span className="sr-only">(done)</span> : null}
</li>
))}
</ol>
</EmptyState>
)
}No access, and who to ask
Says whose page it is and asks for access in one press; the button stays under the focus and the answer is a status line.
"use client"
import * as React from "react"
import { LockKeyholeIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
// Nothing is shown because the reader may not see it, which is not the same
// as there being nothing. The page says whose it is and asks for access in one
// press; the request is confirmed in a status line and the button says it is
// done rather than disappearing from under the focus.
export default function EmptyStatePermission() {
const [asked, setAsked] = React.useState(false)
return (
<EmptyState
className="w-full max-w-md"
icon={<LockKeyholeIcon />}
title="Billing is for admins"
description="Invoices, payment methods and the plan are visible to Northwind's owner and admins."
action={
<Button disabled={asked} focusableWhenDisabled onClick={() => setAsked(true)}>
{asked ? "Access requested" : "Ask Sonia for access"}
</Button>
}
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor("Sonia Keller")} alt="" />
<AvatarFallback>SK</AvatarFallback>
</Avatar>
Sonia Keller, owner, looks after billing.
</div>
<p role="status" className="min-h-5 text-sm">
{asked ? "Sonia has your request, and you will hear by email when she answers." : ""}
</p>
</EmptyState>
)
}All caught up
An empty queue in a card as good news, with the last thing done, the next due, and a link to the finished work.
import { CheckCheckIcon } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
// An empty queue is good news, and it says so in the success tone — with a
// word, not the colour alone — then what happened last and when the next
// thing is due, and the way to the finished work.
export default function EmptyStateCaughtUp() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Refunds to approve</CardTitle>
<CardDescription>Over $500, or older than 30 days</CardDescription>
</CardHeader>
<CardContent className="p-0">
<EmptyState
size="sm"
icon={<CheckCheckIcon className="text-success" />}
title="All caught up"
description="Aisha Gallo approved the last one, ORD-100369, on August 29. The next review is on Monday."
action={
<a href="/finance/payments?status=refunded" className={buttonVariants({ size: "sm", variant: "outline" })}>
See approved refunds
</a>
}
/>
</CardContent>
</Card>
)
}Record not found
Names the missing record by its number, gives the likely reasons, and links back to the list or to a search.
import { ArrowLeftIcon, FileQuestionIcon, SearchIcon } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
// A link to a record that is not there. The page names the record it looked
// for, gives the likely reasons, and offers the two ways a reader goes next:
// back to the list, or a search for the one they meant.
export default function EmptyStateNotFound() {
return (
<EmptyState
variant="dashed"
className="w-full max-w-md"
icon={<FileQuestionIcon />}
title={
<>
There is no invoice <span className="font-mono">INV-100412</span>
</>
}
description="It may have been deleted, or the link has a typo. Northwind's invoices run from INV-100001 to INV-100300."
action={
<a href="/finance/invoices" className={buttonVariants({ size: "sm", variant: "outline" })}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" className="rtl:rotate-180" />
Back to invoices
</a>
}
secondaryAction={
<a href="/finance/invoices?search=INV-1004" className={buttonVariants({ size: "sm", variant: "ghost" })}>
<SearchIcon data-icon="inline-start" aria-hidden="true" />
Search invoices
</a>
}
/>
)
}A team of one
Invites exactly the people the directory suggests; their faces are decoration beside the sentence that names them.
import { LinkIcon, UserPlusIcon, UsersIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
const SUGGESTED = ["Kasia Zimmer", "Eleni Engel", "Maren Zimmer"]
// A workspace of one. The button invites exactly the people the directory
// suggests, whose faces sit under it as decoration beside the sentence that
// names them; the quieter way out shares a link instead.
export default function EmptyStateInvite() {
return (
<EmptyState
className="w-full max-w-md"
icon={<UsersIcon />}
title="You are the only one in Northwind Ops"
description="The Team plan has 24 seats left. People you invite see the dashboards and reports you do."
action={
<Button>
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
Invite the three of them
</Button>
}
secondaryAction={
<Button variant="ghost">
<LinkIcon data-icon="inline-start" aria-hidden="true" />
Copy an invite link
</Button>
}
>
<div className="flex items-center gap-3 text-start text-sm text-muted-foreground">
<div aria-hidden="true">
<AvatarGroup size="sm" users={SUGGESTED.map((name) => ({ name, src: avatarFor(name) }))} />
</div>
<p>Kasia Zimmer, Eleni Engel and Maren Zimmer have Northwind addresses but no seat here.</p>
</div>
</EmptyState>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | React.ReactNode | — | The one line that says what is missing; write it as a fact, not an apology. |
| description | React.ReactNode | — | A sentence or two on how the region fills up. |
| icon | React.ReactNode | — | Sits in a muted tile above the title; sized to 4 unless it sets its own size. |
| action | React.ReactNode | — | The one thing to do next, usually a button. |
| secondaryAction | React.ReactNode | — | A quieter way out, placed beside the action. |
| size | "sm" | "default" | "default" | sm tightens the padding and drops the title, description, and icon a step. |
| variant | "default" | "dashed" | "default" | dashed draws its own placeholder frame, for a region with no card of its own. |
Dependencies
Registry
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
const emptyStateVariants = cva("", {
variants: {
variant: {
// Nothing of its own: the surrounding card or table already frames it.
default: "",
dashed: "rounded-lg border border-dashed border-border",
},
size: {
default: "gap-4 p-8",
sm: "gap-3 p-5",
},
},
defaultVariants: { variant: "default", size: "default" },
})
const emptyStateMediaVariants = cva("", {
variants: {
size: {
default: "",
sm: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
},
},
defaultVariants: { size: "default" },
})
// On the face's own spacing: the upstream title tightens it, and nothing at or
// under 16px carries negative tracking in this kit.
const emptyStateTitleVariants = cva("tracking-normal", {
variants: {
size: { default: "text-base", sm: "text-sm" },
},
defaultVariants: { size: "default" },
})
const emptyStateDescriptionVariants = cva("", {
variants: {
size: { default: "", sm: "text-xs" },
},
defaultVariants: { size: "default" },
})
// `title` is content here, not the HTML tooltip attribute, so it replaces it.
export type EmptyStateProps = Omit<React.ComponentProps<"div">, "title"> & {
/** Sits in a muted tile above the title. Sized to 4 unless it sets its own size. */
icon?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
/** The one thing to do next — a button, a link. */
action?: React.ReactNode
/** A quieter way out, placed beside the action. */
secondaryAction?: React.ReactNode
size?: NonNullable<VariantProps<typeof emptyStateVariants>["size"]>
/** dashed draws its own placeholder frame, for a region that has no card of its own. */
variant?: NonNullable<VariantProps<typeof emptyStateVariants>["variant"]>
}
function EmptyState({
className,
icon,
title,
description,
action,
secondaryAction,
size = "default",
variant = "default",
children,
...props
}: EmptyStateProps) {
const hasActions = Boolean(action) || Boolean(secondaryAction)
return (
<Empty
data-slot="empty-state"
data-variant={variant}
data-size={size}
className={cn(emptyStateVariants({ variant, size }), className)}
{...props}
>
<EmptyHeader>
{icon ? (
<EmptyMedia variant="icon" className={cn(emptyStateMediaVariants({ size }))}>
{icon}
</EmptyMedia>
) : null}
<EmptyTitle className={cn(emptyStateTitleVariants({ size }))}>{title}</EmptyTitle>
{description ? (
<EmptyDescription className={cn(emptyStateDescriptionVariants({ size }))}>
{description}
</EmptyDescription>
) : null}
</EmptyHeader>
{hasActions ? (
<EmptyContent className="flex-row flex-wrap items-center justify-center gap-2">
{action}
{secondaryAction}
</EmptyContent>
) : null}
{children}
</Empty>
)
}
export { EmptyState, emptyStateVariants }