Empty

Displays an empty state.

No projects yet

You haven't created any projects yet. Get started by creating your first project.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import { HStack } from "@/components/ui/stack"
import { FolderCode } from "lucide-react"

const styles = stylex.create({
  empty: { width: "100%" },
})

export default function EmptyDemo() {
  return (
    <Empty xstyle={styles.empty}>
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <FolderCode />
        </EmptyMedia>
        <EmptyTitle>No projects yet</EmptyTitle>
        <EmptyDescription>
          You haven&apos;t created any projects yet. Get started by creating your first project.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <HStack gap={2}>
          <Button>Create project</Button>
          <Button variant="secondary">Import project</Button>
        </HStack>
      </EmptyContent>
    </Empty>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and breakpoints.stylex.ts from the same page. The icon tile comes from states.ts. Empty also needs interop.css, which sizes the icon.

Copy and paste the following code into your project.

components/ui/empty.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { breakpoints } from "@/theme/breakpoints.stylex"
import { states } from "@/theme/states"
import { color, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: {
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    justifyContent: "center",
    flexGrow: 1,
    minWidth: 0,
    rowGap: space.s6,
    padding: { default: space.s6, [breakpoints.md]: space.s12 },
    borderRadius: radius.xl,
    backgroundColor: color.tray,
    color: color.foreground,
    textAlign: "center",
    textWrap: "balance",
  },
  header: {
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    rowGap: space.s1,
    maxWidth: "24rem",
  },
  media: {
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    flexShrink: 0,
    marginBlockEnd: space.s3,
  },
  title: {
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
  },
  description: {
    color: color.mutedForeground,
    fontSize: text.sm,
    lineHeight: leading.sm,
  },
  content: {
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    rowGap: space.s4,
    width: "100%",
    maxWidth: "24rem",
    minWidth: 0,
    fontSize: text.sm,
    lineHeight: leading.sm,
  },
})

const mediaVariants = stylex.create({
  default: {},
  icon: {
    width: space.s10,
    height: space.s10,
    "--carbon-icon-size": space.s5,
  },
})

const emptyStyles = {
  base: styles.root,
  header: styles.header,
  media: styles.media,
  mediaVariants: { default: mediaVariants.default, icon: [states.iconTile, mediaVariants.icon] },
  title: styles.title,
  description: styles.description,
  content: styles.content,
}

type EmptyMediaVariant = keyof typeof mediaVariants

type Overrides = { xstyle?: StyleXStyles; className?: string }

type DivProps = useRender.ComponentProps<"div"> & Overrides

function Empty({ render, ref, style, xstyle, className, ...props }: DivProps) {
  const sx = stylex.props(styles.root, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "empty",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function EmptyHeader({ render, ref, style, xstyle, className, ...props }: DivProps) {
  const sx = stylex.props(styles.header, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "empty-header",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function EmptyMedia({
  variant = "default",
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: DivProps & { variant?: EmptyMediaVariant }) {
  const sx = stylex.props(
    styles.media,
    variant === "icon" && states.iconTile,
    mediaVariants[variant],
    xstyle,
  )
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "empty-media",
      "data-variant": variant,
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function EmptyTitle({ render, ref, style, xstyle, className, ...props }: DivProps) {
  const sx = stylex.props(styles.title, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "empty-title",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function EmptyDescription({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"p"> & Overrides) {
  const sx = stylex.props(styles.description, xstyle)
  return useRender({
    defaultTagName: "p",
    render,
    ref,
    props: {
      "data-slot": "empty-description",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function EmptyContent({ render, ref, style, xstyle, className, ...props }: DivProps) {
  const sx = stylex.props(styles.content, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "empty-content",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

export {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
  emptyStyles,
  type EmptyMediaVariant,
}

Update the import paths to match your project setup.

Usage

import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
<Empty>
  <EmptyHeader>
    <EmptyMedia variant="icon">
      <Icon />
    </EmptyMedia>
    <EmptyTitle>No data</EmptyTitle>
    <EmptyDescription>No data found</EmptyDescription>
  </EmptyHeader>
  <EmptyContent>
    <Button>Add data</Button>
  </EmptyContent>
</Empty>

Composition

Empty
├── EmptyHeader
│   ├── EmptyMedia
│   ├── EmptyTitle
│   └── EmptyDescription
└── EmptyContent

EmptyHeader holds the media, title and description close together. EmptyContent follows a wider gap below it.

Avatar

Leave EmptyMedia on its default variant to show an avatar or an image of your own instead of the icon tile.

CN
User offline

This user is currently offline. You can leave a message to notify them or try again later.

import * as stylex from "@stylexjs/stylex"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

const styles = stylex.create({
  empty: { width: "100%" },
})

export default function EmptyAvatar() {
  return (
    <Empty xstyle={styles.empty}>
      <EmptyHeader>
        <EmptyMedia>
          <Avatar size="lg">
            <AvatarImage src="https://github.com/shadcn.png" alt="" />
            <AvatarFallback>CN</AvatarFallback>
          </Avatar>
        </EmptyMedia>
        <EmptyTitle>User offline</EmptyTitle>
        <EmptyDescription>
          This user is currently offline. You can leave a message to notify them or try again later.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button size="sm" variant="secondary">
          Leave message
        </Button>
      </EmptyContent>
    </Empty>
  )
}

In a table

Inside a surface that already has a fill, such as a table's body, remove the tray with xstyle.

EndpointEventsSuccess rate
No endpoints

Add an endpoint to start receiving events.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { color, space } from "@/theme/tokens.stylex"
import { Plus, Webhook } from "lucide-react"

const styles = stylex.create({
  empty: { backgroundColor: color.transparent, paddingBlock: space.s10, whiteSpace: "normal" },
})

export default function EmptyTable() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Endpoint</TableHead>
          <TableHead>Events</TableHead>
          <TableHead align="end">Success rate</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          <TableCell colSpan={3}>
            <Empty xstyle={styles.empty}>
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <Webhook />
                </EmptyMedia>
                <EmptyTitle>No endpoints</EmptyTitle>
                <EmptyDescription>Add an endpoint to start receiving events.</EmptyDescription>
              </EmptyHeader>
              <Button size="sm" variant="secondary" iconPosition="start">
                <Plus />
                Add endpoint
              </Button>
            </Empty>
          </TableCell>
        </TableRow>
      </TableBody>
    </Table>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part renders through Base UI's useRender, and takes xstyle, className and inline style alongside every attribute of its element. EmptyDescription renders a p; every other part renders a div.

Empty

A centred column on a tray. It grows to fill a flex parent, and its padding widens from 1.5rem to 3rem at the md breakpoint.

EmptyHeader

A column with a 0.25rem gap, at most 24rem wide.

EmptyMedia

PropTypeDefault
variant"default" | "icon""default"

icon puts the glyph on a 2.5rem tile and sizes it to 1.25rem. default centres whatever it holds.

EmptyTitle

Weight 500 at the body size.

EmptyDescription

Muted text at the body size.

EmptyContent

A column with a 1rem gap for actions or anything else, at most 24rem wide.

emptyStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
baseThe tray and its centred column
headerThe media, title and description column
mediaThe media's box
mediaVariantsdefault, and icon with the shared iconTile
titleThe title's type
descriptionThe muted description
contentThe column below the header

Data attributes

AttributeOnEffect
data-slot="empty"the rootTargets the root from a parent
data-slot="empty-header"the headerTargets the header from a parent
data-slot="empty-media"the mediaTargets the media from a parent
data-variantthe mediaReflects the media's variant prop
data-slot="empty-title"the titleTargets the title from a parent
data-slot="empty-description"the descriptionTargets the description from a parent
data-slot="empty-content"the contentTargets the content from a parent

CSS variables

VariableOnEffect
--carbon-icon-sizethe mediaThe icon's size, read by interop.css. 20px on icon