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.
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
EmptyStateby default. It is what the rest of the package uses —DataTablerenders 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.
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.
mediawins when both are given — the icon branch never runs, so the two are alternatives, not layers.- The
iconpath wraps your icon in<EmptyMedia variant="icon">: a 40pxbg-mutedchip withtext-foregroundon it, scaling an unsized SVG to 24px, witharia-hidden="true"applied for you. - The
mediapath wraps it in the plain variant: no background, no size, noaria-hidden. All three are yours — the illustration above sets its ownsize-24, its owntext-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
Connected apps
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.
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.
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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | React.ReactNode | — | The 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. |
description | React.ReactNode | — | Supporting text under the title, capped at max-w-sm by EmptyHeader. A direct <a> child is underlined automatically. |
icon | IconComponent | — | The icon component itself (Inbox), never an element (<Inbox />). Rendered aria-hidden inside a 40px muted rounded chip, with an unsized SVG scaled to 24px. |
media | React.ReactNode | — | Arbitrary 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. |
action | React.ReactNode | — | Buttons 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. |
bordered | boolean | false | Adds 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. |
className | string | — | Merged last onto the root, so it overrides both the size and bordered classes. |
...rest | Omit<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:
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Sizing 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-hidden | boolean | "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
titleanddescription. The icon is renderedaria-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
mediais not hidden for you. Putaria-hidden="true"on it yourself, or give it a real accessible name if it genuinely adds meaning the text does not. EmptyTitleis 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"oraria-live="polite"goes straight through...restonto that div, or onto the region that contains it. - Links inside the description are underlined by
EmptyDescriptionas 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
actionare ordinaryButtons: 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-mutedfilled withtext-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.