# Empty

> Empty state whose icon is an unlit neon tube that flickers on once there's an action to take.

Category: Feedback. Docs: https://neonkit.heyaadi.com/components/empty

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/empty.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/empty`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty"

<Empty>
  <EmptyMedia variant="tube">
    <Rocket />
  </EmptyMedia>
  <EmptyTitle>No deployments yet</EmptyTitle>
  <EmptyDescription>Push to main and it builds on its own.</EmptyDescription>
  <EmptyContent>
    <Button variant="solid">Deploy main</Button>
  </EmptyContent>
</Empty>
```

## Examples

### Tube media

The icon is drawn as an unlit glass tube. There's something to press, so it flickers on.

```tsx
import { Rocket } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";

export default function EmptyDemo() {
  return (
    <Empty>
      <EmptyMedia variant="tube">
        <Rocket />
      </EmptyMedia>
      <EmptyTitle>No deployments yet</EmptyTitle>
      <EmptyDescription>
        Push to <code className="font-mono text-[13px] text-white/80">main</code> and acme-web builds on its own,
        or ship the current branch from here.
      </EmptyDescription>
      <EmptyContent>
        <Button variant="solid">Deploy main</Button>
        <Button variant="ghost" asChild>
          <a href="#cli">Use the CLI</a>
        </Button>
      </EmptyContent>
    </Empty>
  );
}
```

### Lit by the action

As a viewer there's nothing to do and the tube stays dark. Switch to admin: the button appears and the tube ignites.

```tsx
"use client";

import * as React from "react";
import { CalendarDays } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";

export default function EmptyPermission() {
  const [role, setRole] = React.useState("viewer");
  const canSchedule = role === "admin";

  return (
    <div className="flex w-full flex-col items-center gap-2">
      <ToggleGroup aria-label="Signed in as" size="sm" value={role} onValueChange={(value) => setRole(value as string)}>
        <ToggleGroupItem value="viewer">Viewer</ToggleGroupItem>
        <ToggleGroupItem value="admin">Admin</ToggleGroupItem>
      </ToggleGroup>
      <Empty>
        <EmptyMedia variant="tube">
          <CalendarDays />
        </EmptyMedia>
        <EmptyTitle>Nothing scheduled this week</EmptyTitle>
        <EmptyDescription>
          {canSchedule
            ? "Releases you schedule show up here, with who signed them off."
            : "Only admins can schedule releases. Ask Ana or Tomás to add one."}
        </EmptyDescription>
        <EmptyContent>{canSchedule && <Button>Schedule a release</Button>}</EmptyContent>
      </Empty>
    </div>
  );
}
```

### No results

The icon tile, for empty states that don't need light.

```tsx
"use client";

import * as React from "react";
import { SearchX } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";

export default function EmptySearch() {
  return (
    <Empty size="sm">
      <EmptyMedia variant="icon">
        <SearchX />
      </EmptyMedia>
      <EmptyTitle>No invoices match “Q3 refund”</EmptyTitle>
      <EmptyDescription>
        Filters: paid, last 90 days, EUR. Refunds are listed under Credit notes.
      </EmptyDescription>
      <EmptyContent>
        <Button variant="soft" size="sm">
          Clear filters
        </Button>
        <Button variant="ghost" size="sm">
          Search credit notes
        </Button>
      </EmptyContent>
    </Empty>
  );
}
```

### Aligned to the start

align="start" for panels and side columns, where a centred block floats.

```tsx
import { Cable } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";

export default function EmptyPanel() {
  return (
    <div className="grid min-w-0 w-full max-w-3xl overflow-hidden rounded-xl border border-white/[0.08] sm:grid-cols-[200px_1fr]">
      <nav aria-label="Settings" className="hidden flex-col gap-0.5 border-r border-white/[0.06] p-3 text-sm sm:flex">
        {["General", "Domains", "Webhooks", "Environment"].map((label) => (
          <span
            key={label}
            className={
              label === "Webhooks"
                ? "rounded-md bg-white/[0.06] px-2.5 py-1.5 text-white/90"
                : "px-2.5 py-1.5 text-white/55"
            }
          >
            {label}
          </span>
        ))}
      </nav>
      <Empty align="start" className="py-12 sm:px-10">
        <EmptyMedia variant="tube">
          <Cable />
        </EmptyMedia>
        <EmptyTitle>No webhooks</EmptyTitle>
        <EmptyDescription>
          Get a signed POST on every deploy, failed build or domain change. Most teams point one at Slack and
          one at their incident tool.
        </EmptyDescription>
        <EmptyContent>
          <Button variant="outline" size="sm">
            Add endpoint
          </Button>
          <a href="#events" className="px-2 text-[13px] text-white/55 underline decoration-white/20 underline-offset-4 hover:text-white/80">
            See the 18 event types
          </a>
        </EmptyContent>
      </Empty>
    </div>
  );
}
```

## API reference

### Empty

The block. Renders a div and accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"center" \| "start"` | `"center"` | Centred, or flush left. |
| `size` | `"sm" \| "md"` | `"md"` | Vertical padding: 32 or 56px. |
| `color` | `NeonColor` | `inherited` | Color of the lit tube. |

### EmptyMedia

Holds an icon or illustration. Decorative, so it's hidden from screen readers.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "icon" \| "tube"` | `"default"` | Bare, an icon on a quiet tile, or the icon as a neon tube. |
| `lit` | `boolean` | — | Tube only: force it on, or keep it dark, instead of following EmptyContent. |

### EmptyTitle, EmptyDescription, EmptyContent

Text and actions. Each accepts native attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `EmptyTitle` | `h3` | — | An h3, to sit under a page or panel h2. |
| `EmptyDescription` | `p` | — | Capped at 42 characters a line, balanced. |
| `EmptyContent` | `div` | — | Actions. An enabled button or link in here is what lights the tube. |

## Accessibility

- The tube follows EmptyContent through CSS :has(), so an action that appears later still lights it, with no JavaScript.
- Hovering or focusing the action turns the tube's halo up.
- With reduced motion the tube switches on without the flicker.

## Source

`components/ui/empty.tsx`

```tsx
import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

/*
 * The tube media is an unlit glass outline until the empty state holds something to press.
 * `:has()` watches EmptyContent, so an action that shows up later (after a permission check,
 * say) still flickers the tube on, and hovering or focusing it turns the tube up a notch.
 */
const styles = `
@keyframes nk-empty-ignite{0%{opacity:0}8%{opacity:.75}12%{opacity:.08}22%{opacity:.9}27%{opacity:.3}40%,100%{opacity:1}}
[data-nk-empty]:has([data-nk-empty-content] :is(a[href],button:not(:disabled),[role=button])) [data-nk-tube=auto] [data-nk-tube-lit]{animation:nk-empty-ignite .9s linear .3s both}
[data-nk-tube=on] [data-nk-tube-lit]{animation:nk-empty-ignite .9s linear .15s both}
[data-nk-empty]:has([data-nk-empty-content] :is(a[href],button,[role=button]):is(:hover,:focus-visible)) [data-nk-tube=auto] [data-nk-tube-halo]{opacity:1}
@media (prefers-reduced-motion:reduce){[data-nk-tube-lit]{animation-duration:1ms!important;animation-delay:0s!important}[data-nk-tube-halo]{transition:none}}
`;

export interface EmptyProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Center everything, or set it flush left for panels and side columns. */
  align?: "center" | "start";
  size?: "sm" | "md";
}

const Empty = React.forwardRef<HTMLDivElement, EmptyProps>(
  ({ className, style, color, align = "center", size = "md", children, ...props }, ref) => (
    <div
      ref={ref}
      data-nk-empty=""
      data-align={align}
      style={neonVars(color, style)}
      className={cn(
        "[--neon:var(--neon-accent,34_211_238)]",
        "group/empty flex w-full min-w-0 flex-col text-balance text-white/90",
        align === "center" ? "items-center text-center" : "items-start text-left",
        size === "md" ? "px-6 py-14" : "px-4 py-8",
        className,
      )}
      {...props}
    >
      <style>{styles}</style>
      {children}
    </div>
  ),
);
Empty.displayName = "Empty";

export interface EmptyMediaProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * `icon` sets the icon on a quiet tile. `tube` draws it as an unlit neon tube that flickers on
   * once EmptyContent holds an enabled button or link.
   */
  variant?: "default" | "icon" | "tube";
  /** Tube only: force it on or keep it dark, instead of following the actions. */
  lit?: boolean;
}

const EmptyMedia = React.forwardRef<HTMLDivElement, EmptyMediaProps>(
  ({ className, variant = "default", lit, children, ...props }, ref) => {
    if (variant === "tube") {
      return (
        <div
          ref={ref}
          aria-hidden
          data-nk-tube={lit === undefined ? "auto" : lit ? "on" : "off"}
          className={cn(
            "relative mb-6 grid size-16 shrink-0 place-items-center [&>*]:col-start-1 [&>*]:row-start-1",
            "[&_svg]:size-14 [&_svg]:overflow-visible [&_svg]:[stroke-linecap:round] [&_svg]:[stroke-linejoin:round]",
            className,
          )}
          {...props}
        >
          {/* Unlit glass: a wide faint body with a thin filament line inside it. */}
          <span className="text-white/[0.06] [&_svg]:[stroke-width:3.5]">{children}</span>
          <span className="text-white/[0.22] [&_svg]:[stroke-width:1]">{children}</span>
          {/* Lit: colored gas around a white-hot core, with a halo that grows when the action is hovered. */}
          <span data-nk-tube-lit="" className="grid opacity-0 [&>*]:col-start-1 [&>*]:row-start-1">
            <span
              data-nk-tube-halo=""
              className="text-[rgb(var(--neon))] opacity-0 [filter:blur(10px)_opacity(var(--neon-glow,1))] transition-opacity duration-300 [&_svg]:[stroke-width:4]"
            >
              {children}
            </span>
            <span className="text-[rgb(var(--neon))] [filter:drop-shadow(0_0_2px_rgb(var(--neon)/var(--neon-glow,1)))_drop-shadow(0_0_10px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))))] [&_svg]:[stroke-width:2]">
              {children}
            </span>
            <span className="text-white/80 [&_svg]:[stroke-width:0.6]">{children}</span>
          </span>
        </div>
      );
    }

    return (
      <div
        ref={ref}
        aria-hidden
        data-variant={variant}
        className={cn(
          "relative mb-5 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
          variant === "icon" &&
            "size-12 rounded-xl border border-white/[0.08] bg-white/[0.03] text-white/55 shadow-[inset_0_1px_0_rgb(255_255_255/0.04)] [&_svg]:size-6",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    );
  },
);
EmptyMedia.displayName = "EmptyMedia";

const EmptyTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
  ({ className, ...props }, ref) => (
    <h3
      ref={ref}
      className={cn("text-base font-semibold leading-6 tracking-[-0.01em] text-white/90", className)}
      {...props}
    />
  ),
);
EmptyTitle.displayName = "EmptyTitle";

const EmptyDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
  ({ className, ...props }, ref) => (
    <p
      ref={ref}
      className={cn(
        "mt-1.5 max-w-[42ch] text-sm leading-6 text-white/55",
        "[&_a]:text-white/80 [&_a]:underline [&_a]:decoration-white/25 [&_a]:underline-offset-4 hover:[&_a]:decoration-[rgb(var(--neon)/0.8)]",
        className,
      )}
      {...props}
    />
  ),
);
EmptyDescription.displayName = "EmptyDescription";

const EmptyContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      data-nk-empty-content=""
      className={cn(
        "mt-6 flex w-full max-w-sm flex-wrap items-center gap-2 text-sm text-white/55",
        "group-data-[align=center]/empty:justify-center",
        className,
      )}
      {...props}
    />
  ),
);
EmptyContent.displayName = "EmptyContent";

export { Empty, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent };
```
