Composites

Empty State

One prop-driven component for the four things every empty region needs — icon, title, description, action — so “nothing here yet” reads identically in Tasks, CRM and HR. It wraps the Empty primitives and adds no state of its own, so it renders straight from a Server Component.

Basic usage

Four props and you are done. Everything is optional except title — and note that EmptyState ships no default strings at all, so nothing here needs translating out of Vietnamese the way DataTable or TablePagination do. Every word on screen is one you passed.

No messages yet

EmptyState or Empty?

Empty is the Tier 1 primitive: a set of composable parts — Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent — that you arrange yourself. EmptyState is the Tier 2 composite: those same parts, arranged once, behind seven props.

  • Reach for EmptyState by default. It is what the rest of the package uses — DataTable renders one for its own no-rows case.
  • Drop to the primitives when the arrangement is different — an input or a form inside the content slot, two stacked media blocks, a description that needs to sit above the title, a layout that is not centred.
  • Both are static. Neither carries 'use client', so the choice has no bundle cost either way.
what-empty-state-renders.tsx
import { Inbox, Plus } from 'lucide-react'
import {
  Button,
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@comitor/ui'

/**
 * This is exactly what the EmptyState above expands to. Writing it out is not
 * wrong — it is what the primitives are for — but every hand-rolled copy is a
 * chance for "nothing here yet" to look slightly different in Tasks than it
 * does in CRM, which is the whole reason the composite exists.
 */
export function InboxEmptyByHand() {
  return (
    <Empty data-slot="empty-state" className="border">
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <Inbox aria-hidden="true" />
        </EmptyMedia>
        <EmptyTitle>No messages yet</EmptyTitle>
        <EmptyDescription>
          New conversations land here as soon as somebody writes in.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent className="flex-row justify-center gap-2">
        <Button leftIcon={<Plus className="size-4" />}>Start a conversation</Button>
      </EmptyContent>
    </Empty>
  )
}

Icon and media

The two visual slots take deliberately different things. icon takes a component icon={Inbox}, never icon={<Inbox />} — because a Lucide icon is a forwardRef object rather than a function, so a component and an element cannot be told apart at runtime. Rather than guess and be silently wrong, the package types the slot narrowly and lets the compiler catch it. media is the escape hatch for the arbitrary node.

Nothing archived
  • media wins when both are given — the icon branch never runs, so the two are alternatives, not layers.
  • The icon path wraps your icon in <EmptyMedia variant="icon">: a 40px bg-muted chip with text-foreground on it, scaling an unsized SVG to 24px, with aria-hidden="true" applied for you.
  • The media path wraps it in the plain variant: no background, no size, no aria-hidden. All three are yours — the illustration above sets its own size-24, its own text-muted-foreground, and hides itself from assistive technology.

Sizes and the dashed frame

size="md" is the middle-of-the-page treatment: gap-6 p-6 md:p-12 with a text-lg title. size="sm" pulls that in to gap-4 p-4 md:p-6, shrinks the icon chip from 40px to 36px and the title to text-base — the size for an empty region inside a card, where the page treatment would dwarf the card itself.

Active sessions

No other sessions

Connected apps

Nothing connected

bordered is why the outline is dashed and not solid: the underlying Empty root always carries border-dashed but no border width, so the flag only turns the width on. Use it when the empty state has to draw its own box — inside a card, a panel, a list frame. Leave it off where a frame already exists, which is exactly what DataTable does when it renders its own empty row.

Actions

Whatever you pass to action is dropped into EmptyContent with flex-row justify-center gap-2 — the primitive stacks vertically, the composite overrides it to a centred row, so a primary and a secondary button sit side by side without any layout work from you.

No matching contacts

The link in the description above is underlined without any class from us: EmptyDescription styles [&>a], a direct child selector. Wrap the anchor in a <span> and it silently loses the underline — the one thing distinguishing it from body text without relying on colour.

As a whole page

For a 404 or an error screen, the empty state is the page. One thing to watch: the root carries flex-1, which only means anything inside a flex container. In a plain block parent it does nothing, the box is exactly as tall as its content, and nothing is centred vertically — the wrapper is what does the work.

Page not found

Inside a data table

You rarely have to build this one. DataTable already renders an EmptyState in a cell spanning every column when it has no rows, with icon={Inbox} and the strings from its labels. Change the words through labels; replace the whole node through empty when the empty case deserves an action of its own. Code only here: the live table belongs on the DataTable page, and duplicating its column plumbing would bury the one line that matters.

imports-table.tsx
import { Upload } from 'lucide-react'
import { Button, DataTable, EmptyState } from '@comitor/ui'
import type { DataTableColumn } from '@comitor/ui'

/**
 * DataTable already renders an EmptyState for you when rows is empty — inside a
 * TableCell spanning every column, with icon={Inbox} and the strings from
 * labels.emptyTitle / labels.emptyDescription (Vietnamese by default:
 * "Chưa có dữ liệu" / "Danh sách hiện đang trống.").
 *
 * Two ways to make it yours:
 *   labels — keep the built-in shape, change the words (and the aria strings)
 *   empty  — replace the whole node, when the empty state needs an action
 *
 * The built-in one is NOT bordered: the table's own frame already draws the
 * box, and a second dashed outline inside it just looks like a mistake.
 */
export function ImportsTable<T extends { id: string }>({
  rows,
  columns,
}: {
  rows: T[]
  columns: DataTableColumn<T>[]
}) {
  return (
    <DataTable
      rows={rows}
      columns={columns}
      getRowId={(row) => row.id}
      empty={
        <EmptyState
          icon={Upload}
          title="No imports yet"
          description="Upload a CSV to bring your existing records in."
          action={<Button>Upload a file</Button>}
        />
      }
    />
  )
}

Writing the words

The component is the easy half. Three situations look identical on screen and are not the same thing at all:

  • Nothing yet — first run. Say what will appear here and give the action that creates the first one.
  • No results — a search or filter excluded everything. Name the query, and offer the way back: clearing the filter, not creating a record.
  • Not allowed to see it — do not render an empty state at all. “No activity yet” is false when the log is full and the reader simply lacks permission, and there is nothing in the message to tell them so. Hide the region, or say plainly that access is restricted.
activity-panel.tsx
import { FileClock } from 'lucide-react'
import { EmptyState } from '@comitor/ui'

export function ActivityPanel({ entries, canRead }: { entries: string[]; canRead: boolean }) {
  // Not allowed to see it is NOT the same as there is nothing to see.
  // Render nothing at all rather than an empty state that says the workspace
  // is quiet — that sentence is false, and the reader has no way to know.
  if (!canRead) return null

  if (entries.length === 0) {
    return (
      <EmptyState
        size="sm"
        bordered
        icon={FileClock}
        title="No activity in the last 30 days"
        description="Sign-ins, permission changes and deletions show up here."
      />
    )
  }

  return <ul>{/* … */}</ul>
}

Keep the description to a sentence or two: EmptyHeader caps it at max-w-sm and the text is balanced across lines, so a paragraph turns into a tall grey column.

Props

EmptyState

PropTypeDefaultDescription
titlerequiredReact.ReactNodeThe headline, and the only required prop. This is the sentence that carries the meaning — the icon is decoration. Because the interface is Omit<ComponentProps<"div">, "title">, it replaces the native title attribute.
descriptionReact.ReactNodeSupporting text under the title, capped at max-w-sm by EmptyHeader. A direct <a> child is underlined automatically.
iconIconComponentThe icon component itself (Inbox), never an element (<Inbox />). Rendered aria-hidden inside a 40px muted rounded chip, with an unsized SVG scaled to 24px.
mediaReact.ReactNodeArbitrary artwork — an illustration, an image — used instead of icon. Takes priority when both are passed: the icon branch is skipped entirely, not stacked. The wrapper adds no size, no background and no aria-hidden, so all three are yours to set.
actionReact.ReactNodeButtons under the description. The slot is laid out as a centred flex row with gap-2, so a primary/secondary pair sits side by side rather than stacking.
borderedbooleanfalseAdds the border width to the root, which already carries border-dashed — that is why the outline comes out dashed. Use it for an empty region inside a card or panel; skip it where an existing frame (a table, a bordered list) already draws the box.
size'sm' | 'md''md'sm tightens the root to gap-4 p-4 md:p-6, shrinks the icon chip to 36px and the title to text-base — for an empty region inside a card. md keeps the full p-6 md:p-12 treatment for the middle of a page.
classNamestringMerged last onto the root, so it overrides both the size and bordered classes.
...restOmit<React.ComponentProps<"div">, "title">Forwarded to the root <div data-slot="empty-state">. This is where you attach id, role="status" or aria-live when the empty state appears in response to a user action.

IconComponent — the shared icon type of the composite tier, exported from the same module and reused by PageHeader, FilterChips and friends. It is ComponentType of these props:

PropTypeDefaultDescription
classNamestringSizing classes, where the host component passes them — PageHeader sends size-5, while EmptyState sizes the SVG from the chip instead. Any Lucide icon and any hand-drawn SVG component already accepts it.
aria-hiddenboolean | "true" | "false"EmptyState always renders the icon with aria-hidden="true", so your component must accept and forward the prop.

Accessibility

  • The whole message lives in title and description. The icon is rendered aria-hidden="true" and carries no information a screen reader can reach — never let the picture be the only thing that says what happened.
  • An illustration passed through media is not hidden for you. Put aria-hidden="true" on it yourself, or give it a real accessible name if it genuinely adds meaning the text does not.
  • EmptyTitle is a <div>, not a heading — deliberate, because the same component appears inside a card, inside a table cell and in the middle of a page, and a hard-coded <h3> would punch a hole in somebody's document outline. When the empty state is the page, pass your own heading as the node: title={<h1>Page not found</h1>}.
  • Nothing announces itself. The root is a plain <div> with no role and no live region, so swapping a list out for an empty state after a search is silent. Attach your own — role="status" or aria-live="polite" goes straight through ...rest onto that div, or onto the region that contains it.
  • Links inside the description are underlined by EmptyDescription as a direct child, satisfying WCAG 1.4.1 — the link is distinguishable without relying on colour. Nest the anchor deeper and the rule stops applying.
  • Buttons in action are ordinary Buttons: keyboard-focusable, with the package focus ring. Their order in the DOM is their tab order, so put the primary action first.
  • The icon chip is bg-muted filled with text-foreground — a background/foreground pair from the role tokens, not a brand tint — so it holds up in both palettes and in both themes without a per-app override.
  • An empty state that lies is worse than none: if the region is empty because the reader lacks permission, render nothing rather than a sentence claiming there is no data.