Empty
The state a list is in before it has anything in it.
import { PlusIcon, InboxIcon } from "@implementjs/lucide";
import { Button } from "@/lib/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/lib/components/ui/empty";
export default function EmptyDemo() {
return Empty(
{ class: "w-full max-w-md border" },
EmptyHeader(
EmptyMedia({ variant: "icon" }, InboxIcon({ "aria-hidden": true })),
EmptyTitle("No projects yet"),
EmptyDescription("Projects group your deployments, domains, and environment variables."),
),
EmptyContent(Button({ size: "sm" }, PlusIcon({ "aria-hidden": true }), "New project")),
);
}Installation
npx jsrepo add @implementjs/ui/empty
It installs tailwind-variants at the same time.
Copy the file below to src/lib/components/ui/empty.ts. It imports cn, so copy utils.ts to src/lib/utils.ts too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install tailwind-variants
import { Div, type Child, type ElementProps } from "@implementjs/core";
import { createComponent } from "@implementjs/primitives";
import { tv, type VariantProps } from "tailwind-variants";
import { cn } from "@/lib/utils";
export const emptyMediaVariants = tv({
base: "mb-2 flex shrink-0 items-center justify-center [&_svg:not([class*='size-'])]:size-6",
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground",
},
},
defaultVariants: { variant: "default" },
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
export type EmptyProps = ElementProps<"div">;
export type EmptyHeaderProps = ElementProps<"div">;
export type EmptyMediaProps = ElementProps<"div"> & VariantProps<typeof emptyMediaVariants>;
export type EmptyTitleProps = ElementProps<"div">;
export type EmptyDescriptionProps = ElementProps<"div">;
export type EmptyContentProps = ElementProps<"div">;
/**
* The state a list is in before it has anything in it. An empty state that
* says what the thing is and offers the first step is worth more than a
* blank panel, which is all this arranges: media, a title, a line of
* explanation, and somewhere to put the action.
*/
export const Empty = createComponent(function Empty(
{ class: className, ...props }: EmptyProps,
...children: Child[]
) {
return Div(
{
...props,
"data-slot": "empty",
class: cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className,
),
},
...children,
);
});
export const EmptyHeader = createComponent(function EmptyHeader(
{ class: className, ...props }: EmptyHeaderProps,
...children: Child[]
) {
return Div(
{
...props,
"data-slot": "empty-header",
class: cn("flex max-w-sm flex-col items-center gap-2 text-center", className),
},
...children,
);
});
/** The icon or illustration above the title. `icon` gives it a filled tile. */
export const EmptyMedia = createComponent(function EmptyMedia(
{ class: className, variant = "default", ...props }: EmptyMediaProps,
...children: Child[]
) {
return Div(
{
...props,
"data-slot": "empty-media",
"data-variant": variant,
class: cn(emptyMediaVariants({ variant }), className),
},
...children,
);
});
export const EmptyTitle = createComponent(function EmptyTitle(
{ class: className, ...props }: EmptyTitleProps,
...children: Child[]
) {
return Div(
{
...props,
"data-slot": "empty-title",
class: cn("text-lg font-medium tracking-tight", className),
},
...children,
);
});
export const EmptyDescription = createComponent(function EmptyDescription(
{ class: className, ...props }: EmptyDescriptionProps,
...children: Child[]
) {
return Div(
{
...props,
"data-slot": "empty-description",
class: cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
),
},
...children,
);
});
/** Whatever comes after the message — a button, a form, a hint. */
export const EmptyContent = createComponent(function EmptyContent(
{ class: className, ...props }: EmptyContentProps,
...children: Child[]
) {
return Div(
{
...props,
"data-slot": "empty-content",
class: cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className,
),
},
...children,
);
});Usage
An empty state that says what the thing is and offers the first step is worth more than a blank panel. That is all this arranges.
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/lib/components/ui/empty";
Empty(
EmptyHeader(
EmptyMedia({ variant: "icon" }, InboxIcon({ "aria-hidden": true })),
EmptyTitle("No projects yet"),
EmptyDescription("Projects group your deployments and domains."),
),
EmptyContent(Button({ size: "sm" }, "New project")),
);
The border is opt-in
The root sets border-dashed but no border, so by default it is a spacing container with no outline. Add border when the empty state should read as a panel:
Empty({ class: "border" } /* ... */);
Media
EmptyMedia is plain by default and gives a filled, rounded tile under variant: "icon" — the difference between an illustration and a glyph.
API Reference
Empty
The state a list is in before it has anything in it: centered, dashed, and roomy. Renders a Div; extra props are forwarded onto it.
EmptyHeader
Media, title, and description as one centered stack. Renders a Div; extra props are forwarded onto it.
EmptyMedia
The icon or illustration above the title. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "icon" | "default" | icon puts the glyph in a filled, rounded tile. |
EmptyTitle
What is missing. Renders a Div; extra props are forwarded onto it.
EmptyDescription
Why it is worth having, in a line. Renders a Div; extra props are forwarded onto it.
EmptyContent
Whatever comes after the message — a button, a form, a hint. Renders a Div; extra props are forwarded onto it.