HextaUI

Empty

A placeholder for screens with nothing to show yet, with an icon, a message and the next action.

You’re all caught up
New mentions, reviews and replies land here. Nothing needs you right now.
import { IconInbox } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyDemo() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia variant="stack">
          <IconInbox />
        </EmptyMedia>
        <EmptyTitle>You’re all caught up</EmptyTitle>
        <EmptyDescription>
          New mentions, reviews and replies land here. Nothing needs you right
          now.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button variant="outline" size="sm">
          Notification settings
        </Button>
      </EmptyContent>
    </Empty>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/empty.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
<Empty>
  <EmptyHeader>
    <EmptyMedia variant="stack">
      <IconFolder />
    </EmptyMedia>
    <EmptyTitle>No projects yet</EmptyTitle>
    <EmptyDescription>Create a project to get started.</EmptyDescription>
  </EmptyHeader>
  <EmptyContent>
    <Button>Create project</Button>
  </EmptyContent>
</Empty>
Empty
├── EmptyHeader
│   ├── EmptyMedia
│   ├── EmptyTitle
│   └── EmptyDescription
└── EmptyContent

Entrance

When an empty state replaces content, its parts fade up one after another: media, title, description, then actions. Dismiss the notifications to see it. Under reduced motion it just appears. Set animated={false} for empty states that are there from the first paint.

  • Jun approved your pull request
  • Mira mentioned you in #design
  • Deploy to production finished
"use client"

import * as React from "react"
import { IconBellOff, IconX } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

const initial = [
  "Jun approved your pull request",
  "Mira mentioned you in #design",
  "Deploy to production finished",
]

export function EmptyTransition() {
  const [items, setItems] = React.useState(initial)

  return (
    <div className="flex min-h-64 w-full max-w-sm flex-col">
      {items.length > 0 ? (
        <ul className="flex flex-col gap-1 rounded-xl border p-1.5 text-sm">
          {items.map((item) => (
            <li
              key={item}
              className="flex items-center justify-between gap-2 rounded-md py-1 ps-2.5"
            >
              <span className="min-w-0 truncate">{item}</span>
              <Button
                variant="ghost"
                size="icon-sm"
                aria-label={`Dismiss “${item}”`}
                onClick={() =>
                  setItems(items.filter((other) => other !== item))
                }
              >
                <IconX />
              </Button>
            </li>
          ))}
        </ul>
      ) : (
        <Empty variant="outline" size="sm">
          <EmptyHeader>
            <EmptyMedia variant="stack">
              <IconBellOff />
            </EmptyMedia>
            <EmptyTitle>No notifications</EmptyTitle>
            <EmptyDescription>
              Dismissed items are gone for good.
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Button
              variant="outline"
              size="sm"
              onClick={() => setItems(initial)}
            >
              Bring them back
            </Button>
          </EmptyContent>
        </Empty>
      )}
    </div>
  )
}

Outline

variant="outline" draws a dashed hairline border, the usual way to mark a drop zone or an area waiting for content.

Cloud storage is empty
Upload files to keep them in sync across every device.
import { IconCloudUpload } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyOutline() {
  return (
    <Empty variant="outline">
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <IconCloudUpload />
        </EmptyMedia>
        <EmptyTitle>Cloud storage is empty</EmptyTitle>
        <EmptyDescription>
          Upload files to keep them in sync across every device.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button variant="outline" size="sm">
          Upload files
        </Button>
      </EmptyContent>
    </Empty>
  )
}

Muted

variant="muted" sits on a soft fill. The icon tile switches to a raised white surface so it still stands out.

No notifications
You’re all caught up. New notifications will appear here.
import { IconBell } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyMuted() {
  return (
    <Empty variant="muted">
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <IconBell />
        </EmptyMedia>
        <EmptyTitle>No notifications</EmptyTitle>
        <EmptyDescription>
          You’re all caught up. New notifications will appear here.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button variant="outline" size="sm">
          Notification settings
        </Button>
      </EmptyContent>
    </Empty>
  )
}

Small

size="sm" tightens the padding, gaps, icon tile and title for sidebars, popovers and narrow panels.

No messages
Messages from your team land here.
import { IconInbox } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptySmall() {
  return (
    <div className="w-full max-w-xs">
      <Empty variant="outline" size="sm">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <IconInbox />
          </EmptyMedia>
          <EmptyTitle>No messages</EmptyTitle>
          <EmptyDescription>
            Messages from your team land here.
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          <Button size="sm">New message</Button>
        </EmptyContent>
      </Empty>
    </div>
  )
}

Avatar

The default EmptyMedia variant only centers its content, so any avatar, badge or illustration fits.

LH
Lena is offline
Leave a message and she’ll see it when she’s back.
import { Avatar, AvatarBadge, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyAvatar() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia>
          <Avatar size="lg">
            <AvatarFallback>LH</AvatarFallback>
            <AvatarBadge status="offline" />
          </Avatar>
        </EmptyMedia>
        <EmptyTitle>Lena is offline</EmptyTitle>
        <EmptyDescription>
          Leave a message and she’ll see it when she’s back.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button size="sm">Leave a message</Button>
      </EmptyContent>
    </Empty>
  )
}

Avatar group

Show who could be here, for invites and shared spaces.

ALGHAT
No team members
Invite your team to collaborate on this project.
import { IconPlus } from "@tabler/icons-react"

import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyAvatarGroup() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia>
          <AvatarGroup>
            <Avatar>
              <AvatarFallback>AL</AvatarFallback>
            </Avatar>
            <Avatar>
              <AvatarFallback>GH</AvatarFallback>
            </Avatar>
            <Avatar>
              <AvatarFallback>AT</AvatarFallback>
            </Avatar>
          </AvatarGroup>
        </EmptyMedia>
        <EmptyTitle>No team members</EmptyTitle>
        <EmptyDescription>
          Invite your team to collaborate on this project.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button size="sm">
          <IconPlus data-icon="inline-start" />
          Invite members
        </Button>
      </EmptyContent>
    </Empty>
  )
}

Image

Give images explicit dimensions so the layout doesn’t shift while they load.

No photos in this album
Drop photos here or add them from your library.
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyImage() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia>
          <img
            src="/preview/landscape.svg"
            alt=""
            width={160}
            height={100}
            className="h-24 w-40 rounded-lg object-cover"
          />
        </EmptyMedia>
        <EmptyTitle>No photos in this album</EmptyTitle>
        <EmptyDescription>
          Drop photos here or add them from your library.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button variant="outline" size="sm">
          Add photos
        </Button>
      </EmptyContent>
    </Empty>
  )
}

No search results

Swap the results for an empty state when filters match nothing, and offer a way out. A visually hidden role="status" announces the count, so screen reader users hear the change without the whole list being read.

1 task

  • Plan the next sprint
"use client"

import * as React from "react"
import { IconFilterOff } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

const tasks = [
  { title: "Write release notes", done: true },
  { title: "Review pull requests", done: true },
  { title: "Plan the next sprint", done: false },
]

export function EmptySearchResults() {
  const [onlyOpen, setOnlyOpen] = React.useState(true)
  const visible = tasks.filter((task) => !onlyOpen || !task.done)
  const [query, setQuery] = React.useState("sprint")
  const results = visible.filter((task) =>
    task.title.toLowerCase().includes(query.toLowerCase())
  )

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <div className="flex flex-wrap gap-2">
        <Button
          variant={query ? "secondary" : "outline"}
          size="sm"
          onClick={() => setQuery(query ? "" : "sprint")}
        >
          {query ? `Search: ${query}` : "No search"}
        </Button>
        <Button
          variant={onlyOpen ? "secondary" : "outline"}
          size="sm"
          onClick={() => setOnlyOpen(!onlyOpen)}
        >
          {onlyOpen ? "Open tasks only" : "All tasks"}
        </Button>
        <Button variant="outline" size="sm" onClick={() => setQuery("design")}>
          Search “design”
        </Button>
      </div>
      <p role="status" className="sr-only">
        {results.length === 1 ? "1 task" : `${results.length} tasks`}
      </p>
      <div>
        {results.length > 0 ? (
          <ul className="flex flex-col gap-1 rounded-xl border p-2 text-sm">
            {results.map((task) => (
              <li key={task.title} className="rounded-md px-2 py-1.5">
                {task.title}
              </li>
            ))}
          </ul>
        ) : (
          <Empty variant="outline" size="sm">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <IconFilterOff />
              </EmptyMedia>
              <EmptyTitle>No matching tasks</EmptyTitle>
              <EmptyDescription>
                Nothing matches “{query}” with the current filters.
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button
                variant="outline"
                size="sm"
                onClick={() => {
                  setQuery("")
                  setOnlyOpen(false)
                }}
              >
                Clear filters
              </Button>
            </EmptyContent>
          </Empty>
        )}
      </div>
    </div>
  )
}

In a card

Use size="sm" with the muted variant to fill a card that has no data yet.

Revenue
Last 30 days
No data yet
Charts appear after your first sale.
import { IconChartBar } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyInCard() {
  return (
    <Card className="w-full max-w-sm">
      <CardHeader>
        <CardTitle>Revenue</CardTitle>
        <CardDescription>Last 30 days</CardDescription>
      </CardHeader>
      <CardContent>
        <Empty variant="muted" size="sm">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <IconChartBar />
            </EmptyMedia>
            <EmptyTitle>No data yet</EmptyTitle>
            <EmptyDescription>
              Charts appear after your first sale.
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Button variant="outline" size="sm">
              Connect a store
            </Button>
          </EmptyContent>
        </Empty>
      </CardContent>
    </Card>
  )
}

In a table

Put it in a single cell that spans every column with colSpan, so the header stays aligned.

InvoiceCustomerAmount
No invoices
Invoices you send will show up here.
import { IconReceipt } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

export function EmptyInTable() {
  return (
    <div className="w-full">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Invoice</TableHead>
            <TableHead>Customer</TableHead>
            <TableHead>Amount</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          <TableRow>
            <TableCell colSpan={3}>
              <Empty size="sm">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <IconReceipt />
                  </EmptyMedia>
                  <EmptyTitle>No invoices</EmptyTitle>
                  <EmptyDescription>
                    Invoices you send will show up here.
                  </EmptyDescription>
                </EmptyHeader>
                <EmptyContent>
                  <Button size="sm">Create invoice</Button>
                </EmptyContent>
              </Empty>
            </TableCell>
          </TableRow>
        </TableBody>
      </Table>
    </div>
  )
}

In a popover

A compact empty state for menus and notification panels.

import { IconBell, IconBellOff } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

export function EmptyInPopover() {
  return (
    <Popover>
      <PopoverTrigger
        render={
          <Button variant="outline" size="icon" aria-label="Notifications" />
        }
      >
        <IconBell />
      </PopoverTrigger>
      <PopoverContent className="w-72">
        <Empty size="sm">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <IconBellOff />
            </EmptyMedia>
            <EmptyTitle>Nothing new</EmptyTitle>
            <EmptyDescription>
              We’ll let you know when something needs your attention.
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      </PopoverContent>
    </Popover>
  )
}

In a command list

<CommandEmpty /> passes the current search to a function child, so the message can quote it.

"use client"

import * as React from "react"
import { IconSearch } from "@tabler/icons-react"

import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyInCommand() {
  const [search, setSearch] = React.useState("zzz")

  return (
    <Command className="w-full max-w-sm">
      <CommandInput
        placeholder="Search people…"
        value={search}
        onValueChange={setSearch}
      />
      <CommandList>
        <CommandEmpty>
          {(search) => (
            <Empty size="sm">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <IconSearch />
                </EmptyMedia>
                <EmptyTitle>No results</EmptyTitle>
                <EmptyDescription>
                  Nothing matches “{search}”. Try another name.
                </EmptyDescription>
              </EmptyHeader>
            </Empty>
          )}
        </CommandEmpty>
        <CommandGroup heading="People">
          <CommandItem>Ada Lovelace</CommandItem>
          <CommandItem>Alan Turing</CommandItem>
          <CommandItem>Grace Hopper</CommandItem>
        </CommandGroup>
      </CommandList>
    </Command>
  )
}

Heading level

The title is a <div> so it never breaks your page outline. Render it as the heading level that fits, and label the region with it.

No drafts

Drafts are saved automatically while you write.

import { IconFileText } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyHeading() {
  return (
    <Empty render={<section aria-labelledby="drafts-empty-title" />}>
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <IconFileText />
        </EmptyMedia>
        <EmptyTitle id="drafts-empty-title" render={<h3 />}>
          No drafts
        </EmptyTitle>
        <EmptyDescription render={<p />}>
          Drafts are saved automatically while you write.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button size="sm">Write a post</Button>
      </EmptyContent>
    </Empty>
  )
}

Long content

Unbroken names and long addresses wrap inside the container, and long button labels truncate.

Nothing found in averyveryverylongunbrokenworkspacenamethatkeepsgoing

We searched every folder shared with [email protected] and found nothing.

Check the sharing settings or ask the owner for access.

import { IconAlertTriangle } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyLongContent() {
  return (
    <div className="w-full max-w-72">
      <Empty variant="outline" size="sm">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <IconAlertTriangle />
          </EmptyMedia>
          <EmptyTitle>
            Nothing found in
            averyveryverylongunbrokenworkspacenamethatkeepsgoing
          </EmptyTitle>
          <EmptyDescription>
            <p>
              We searched every folder shared with
              [email protected] and found
              nothing.
            </p>
            <p>
              Check the <a href="#">sharing settings</a> or ask the owner for
              access.
            </p>
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          <Button variant="outline" size="sm" className="max-w-full">
            <span className="truncate">
              Request access from the workspace owner
            </span>
          </Button>
        </EmptyContent>
      </Empty>
    </div>
  )
}

Right to left

Everything is centered and uses logical properties, so it reads naturally in right-to-left layouts.

لا توجد مشاريع بعد
لم تنشئ أي مشروع حتى الآن. ابدأ مشروعًا جديدًا أو استورد مستودعًا موجودًا. اعرف المزيد
import { IconFolderPlus } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function EmptyRtl() {
  return (
    <div dir="rtl" className="w-full">
      <Empty variant="outline">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <IconFolderPlus />
          </EmptyMedia>
          <EmptyTitle>لا توجد مشاريع بعد</EmptyTitle>
          <EmptyDescription>
            لم تنشئ أي مشروع حتى الآن. ابدأ مشروعًا جديدًا أو استورد مستودعًا
            موجودًا. <a href="#">اعرف المزيد</a>
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          <div className="flex flex-wrap justify-center gap-2">
            <Button>إنشاء مشروع</Button>
            <Button variant="outline">استيراد</Button>
          </div>
        </EmptyContent>
      </Empty>
    </div>
  )
}
  • <EmptyTitle /> renders a <div>. Pass render={<h2 />} (or the level that fits) when the empty state is a section of the page.
  • <EmptyMedia variant="icon" /> is hidden from assistive tech because the title already says what it means. Images in the default variant stay exposed, so give them an alt or alt="".
  • When results disappear after a search or filter, announce the change with a short role="status" message next to the list. Don’t make the whole empty state a live region, since its buttons and links would be read every time.

Every part renders a plain element, accepts its attributes and supports the render prop to swap it.

PropTypeDefault
variant
"default" | "outline" | "muted""default"
sizesm tightens padding, gaps, the icon tile and the title.
"default" | "sm""default"
animatedFade the parts up in sequence when the empty state mounts. Skipped under reduced motion.
booleantrue
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="empty"Target the root in CSS.
data-variantThe current variant.
data-sizeThe current size.
data-animatedPresent when the entrance animation is on.
--empty-paddingPadding on every side. Set by size.
--empty-gapSpace between the header and content. Set by size.
PropTypeDefault
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="empty-header"Target the header in CSS.
PropTypeDefault
varianticon puts an icon in a muted tile. stack raises the tile over two fanned cards that spread when the empty state is hovered. Both hide the media from assistive tech.
"default" | "icon" | "stack""default"
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="empty-icon"Target the media in CSS. Matches shadcn.
data-variantThe current variant.
PropTypeDefault
renderRender a heading, such as render={<h2 />}.
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="empty-title"Target the title in CSS.
PropTypeDefault
renderPlain links inside are underlined. Components rendered as links keep their own style.
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="empty-description"Target the description in CSS.
PropTypeDefault
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="empty-content"Target the actions area in CSS.