Skip to contentVibraUI
Feedback & status

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-state

Needs the @vibra registry in your components.json — set it up once.

Examples

Dashed

The dashed frame at both sizes, for a slot waiting to be filled.

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.

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.

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.

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.

Record not found

Names the missing record by its number, gives the likely reasons, and links back to the list or to a search.

A team of one

Invites exactly the people the directory suggests; their faces are decoration beside the sentence that names them.

Props

PropTypeDefaultDescription
titleReact.ReactNode—The one line that says what is missing; write it as a fact, not an apology.
descriptionReact.ReactNode—A sentence or two on how the region fills up.
iconReact.ReactNode—Sits in a muted tile above the title; sized to 4 unless it sets its own size.
actionReact.ReactNode—The one thing to do next, usually a button.
secondaryActionReact.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

Source

components/ui/empty-state.tsx
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 }