Skip to main content
Nexus Design System
GitHub

EmptyState

EmptyState explains why a list, table or record view has no content yet. Give it an icon, a title, a short description and an optional action.

No contacts yet

Add your first contact to start building your CRM.

'use client';

import { UsersIcon } from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';
import {
  EmptyState,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/nxds/components/empty-state/empty-state';

export default function EmptyStateDefault() {
  return (
    <EmptyState>
      <EmptyStateHeader>
        <EmptyStateMedia variant="icon">
          <UsersIcon aria-hidden />
        </EmptyStateMedia>
        <EmptyStateTitle>No contacts yet</EmptyStateTitle>
        <EmptyStateDescription>
          Add your first contact to start building your CRM.
        </EmptyStateDescription>
      </EmptyStateHeader>
      <EmptyStateContent>
        <Button>Add contact</Button>
      </EmptyStateContent>
    </EmptyState>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add empty-state

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  EmptyState,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/nxds/components/empty-state/empty-state';

Composition

EmptyState
├── EmptyStateHeader
│   ├── EmptyStateMedia
│   ├── EmptyStateTitle
│   └── EmptyStateDescription
└── EmptyStateContent

Examples

Without action

Leave out EmptyStateContent when there is no action to offer.

No results

Try adjusting your filters or search terms.

'use client';

import { UsersIcon } from '@phosphor-icons/react/ssr';

import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/nxds/components/empty-state/empty-state';

export default function EmptyStateWithoutAction() {
  return (
    <EmptyState>
      <EmptyStateHeader>
        <EmptyStateMedia variant="icon">
          <UsersIcon aria-hidden />
        </EmptyStateMedia>
        <EmptyStateTitle>No results</EmptyStateTitle>
        <EmptyStateDescription>
          Try adjusting your filters or search terms.
        </EmptyStateDescription>
      </EmptyStateHeader>
    </EmptyState>
  );
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

Title as heading

Use asChild on EmptyStateTitle to render a real h2 that names its section.

No contacts yet

The empty-state title can be the section heading without adding a duplicate hidden heading.

'use client';

import { UsersIcon } from '@phosphor-icons/react/ssr';

import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/nxds/components/empty-state/empty-state';

export default function EmptyStateTitleAsHeading() {
  return (
    <section aria-labelledby="empty-state-section-heading">
      <EmptyState>
        <EmptyStateHeader>
          <EmptyStateMedia variant="icon">
            <UsersIcon aria-hidden />
          </EmptyStateMedia>
          <EmptyStateTitle asChild>
            <h2 id="empty-state-section-heading">No contacts yet</h2>
          </EmptyStateTitle>
          <EmptyStateDescription>
            The empty-state title can be the section heading without adding a
            duplicate hidden heading.
          </EmptyStateDescription>
        </EmptyStateHeader>
      </EmptyState>
    </section>
  );
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

Bordered

Set bordered to add a dashed frame around the empty state.

No contacts yet

Add your first contact to get started.

'use client';

import { UsersIcon } from '@phosphor-icons/react/ssr';

import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/nxds/components/empty-state/empty-state';

export default function EmptyStateBordered() {
  return (
    <EmptyState bordered>
      <EmptyStateHeader>
        <EmptyStateMedia variant="icon">
          <UsersIcon aria-hidden />
        </EmptyStateMedia>
        <EmptyStateTitle>No contacts yet</EmptyStateTitle>
        <EmptyStateDescription>
          Add your first contact to get started.
        </EmptyStateDescription>
      </EmptyStateHeader>
    </EmptyState>
  );
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

All variants

Compare the icon and default media variants, with and without a frame.

No contacts yet

Add your first contact to get started.

No results

Try adjusting your filters.

'use client';

import { UsersIcon } from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';
import {
  EmptyState,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/nxds/components/empty-state/empty-state';

export default function EmptyStateAllVariants() {
  return (
    <div className="nx:flex nx:flex-col nx:gap-6">
      <EmptyState bordered>
        <EmptyStateHeader>
          <EmptyStateMedia variant="icon">
            <UsersIcon aria-hidden />
          </EmptyStateMedia>
          <EmptyStateTitle>No contacts yet</EmptyStateTitle>
          <EmptyStateDescription>
            Add your first contact to get started.
          </EmptyStateDescription>
        </EmptyStateHeader>
        <EmptyStateContent>
          <Button>Add contact</Button>
        </EmptyStateContent>
      </EmptyState>

      <EmptyState>
        <EmptyStateHeader>
          <EmptyStateMedia variant="default">
            <UsersIcon
              aria-hidden
              className="nx:size-12 nx:text-muted-foreground"
            />
          </EmptyStateMedia>
          <EmptyStateTitle>No results</EmptyStateTitle>
          <EmptyStateDescription>
            Try adjusting your filters.
          </EmptyStateDescription>
        </EmptyStateHeader>
      </EmptyState>
    </div>
  );
}

Props

EmptyState

EmptyState props
PropTypeDefaultDescription
borderedbooleanfalse

Adds a dashed frame. Match a bordered skeleton so swapping from loading doesn't shift the layout.

EmptyStateContent

EmptyStateContent adds no props of its own; it accepts the props of the element or primitive it wraps.

EmptyStateDescription

EmptyStateDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

EmptyStateHeader

EmptyStateHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

EmptyStateMedia

EmptyStateMedia props
PropTypeDefaultDescription
variant"default" | "icon" | null"default"No description

EmptyStateTitle

EmptyStateTitle props
PropTypeDefaultDescription
asChildbooleanfalse

Applies the title styles to a child element, such as an h2 heading.

EmptyState explains why a list, table or record view has no content yet. Give it an icon, a title, a short description and an optional action.

<script setup lang="ts">
import { PhUsers } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
import {
  EmptyState,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/empty-state';
</script>

<template>
  <EmptyState>
    <EmptyStateHeader>
      <EmptyStateMedia variant="icon">
        <PhUsers aria-hidden="true" />
      </EmptyStateMedia>
      <EmptyStateTitle>No contacts yet</EmptyStateTitle>
      <EmptyStateDescription
        >Add your first contact to start building your
        CRM.</EmptyStateDescription
      >
    </EmptyStateHeader>
    <EmptyStateContent>
      <Button>Add contact</Button>
    </EmptyStateContent>
  </EmptyState>
</template>

This example also needs: Button.

npm install @phosphor-icons/vue@^2.2.1

Installation

  1. Install the following dependencies:

    npm install class-variance-authority@^0.7.1 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/empty-state/EmptyState.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface EmptyStateProps {
      /**
       * Adds a dashed frame. Match a bordered skeleton so swapping from loading
       * doesn't shift the layout.
       */
      bordered?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<EmptyStateProps>(), {
      bordered: false,
    });
    </script>
    
    <template>
      <div
        data-slot="empty-state"
        :data-bordered="bordered || undefined"
        :class="
          cn(
            'nx:flex nx:min-w-0 nx:flex-1 nx:flex-col nx:items-center nx:justify-center nx:gap-6 nx:rounded-lg nx:bg-muted-extralight nx:p-6 nx:text-center nx:text-balance',
            bordered &&
              'nx:border-default nx:border-dashed nx:border-border-default',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/empty-state/EmptyStateContent.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface EmptyStateContentProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<EmptyStateContentProps>();
    </script>
    
    <template>
      <div
        data-slot="empty-state-content"
        :class="
          cn(
            'nx:flex nx:w-full nx:max-w-sm nx:min-w-0 nx:flex-col nx:items-center nx:gap-4',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/empty-state/EmptyStateDescription.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface EmptyStateDescriptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<EmptyStateDescriptionProps>();
    </script>
    
    <template>
      <p
        data-slot="empty-state-description"
        :class="
          cn(
            'nx:m-0 nx:typography-body-default nx:text-muted-foreground nx:[&>a]:text-inherit nx:[&>a]:underline nx:[&>a]:underline-offset-4 nx:[&>a:hover]:text-primary-subtle-foreground',
            props.class
          )
        "
      >
        <slot />
      </p>
    </template>
    
    components/empty-state/EmptyStateHeader.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface EmptyStateHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<EmptyStateHeaderProps>();
    </script>
    
    <template>
      <div
        data-slot="empty-state-header"
        :class="
          cn(
            'nx:flex nx:max-w-sm nx:flex-col nx:items-center nx:gap-2',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/empty-state/EmptyStateMedia.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type EmptyStateMediaVariants, emptyStateMediaVariants } from '.';
    
    export interface EmptyStateMediaProps {
      variant?: EmptyStateMediaVariants['variant'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<EmptyStateMediaProps>(), {
      variant: 'default',
    });
    </script>
    
    <template>
      <div
        data-slot="empty-state-media"
        :data-variant="variant"
        :class="cn(emptyStateMediaVariants({ variant }), props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/empty-state/EmptyStateTitle.vue
    <script setup lang="ts">
    import { Primitive, type PrimitiveProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface EmptyStateTitleProps extends PrimitiveProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<EmptyStateTitleProps>(), { as: 'div' });
    </script>
    
    <template>
      <Primitive
        data-slot="empty-state-title"
        :as="as"
        :as-child="asChild"
        :class="cn('nx:m-0 nx:typography-heading-xxsmall', props.class)"
      >
        <slot />
      </Primitive>
    </template>
    
    components/empty-state/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as EmptyState, type EmptyStateProps } from './EmptyState.vue';
    export {
      default as EmptyStateContent,
      type EmptyStateContentProps,
    } from './EmptyStateContent.vue';
    export {
      default as EmptyStateDescription,
      type EmptyStateDescriptionProps,
    } from './EmptyStateDescription.vue';
    export {
      default as EmptyStateHeader,
      type EmptyStateHeaderProps,
    } from './EmptyStateHeader.vue';
    export {
      default as EmptyStateMedia,
      type EmptyStateMediaProps,
    } from './EmptyStateMedia.vue';
    export {
      default as EmptyStateTitle,
      type EmptyStateTitleProps,
    } from './EmptyStateTitle.vue';
    
    export const emptyStateMediaVariants = cva(
      'nx:mb-2 nx:flex nx:shrink-0 nx:items-center nx:justify-center nx:[&_svg]:pointer-events-none nx:[&_svg]:shrink-0',
      {
        variants: {
          variant: {
            default: '',
            icon: 'nx:size-10 nx:rounded-lg nx:bg-muted nx:text-foreground nx:[&_svg]:size-6',
          },
        },
        defaultVariants: {
          variant: 'default',
        },
      }
    );
    
    export type EmptyStateMediaVariants = VariantProps<
      typeof emptyStateMediaVariants
    >;
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { EmptyState, EmptyStateContent, EmptyStateDescription, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@/components/empty-state';

Examples

Without action

Leave out EmptyStateContent when there is no action to offer.

<script setup lang="ts">
import { PhUsers } from '@phosphor-icons/vue';

import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/empty-state';
</script>

<template>
  <EmptyState>
    <EmptyStateHeader>
      <EmptyStateMedia variant="icon">
        <PhUsers aria-hidden="true" />
      </EmptyStateMedia>
      <EmptyStateTitle>No results</EmptyStateTitle>
      <EmptyStateDescription
        >Try adjusting your filters or search terms.</EmptyStateDescription
      >
    </EmptyStateHeader>
  </EmptyState>
</template>

Title as heading

Use as-child on EmptyStateTitle to render a real h2 that names its section.

<script setup lang="ts">
import { PhUsers } from '@phosphor-icons/vue';

import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/empty-state';
</script>

<template>
  <section aria-labelledby="empty-state-section-heading">
    <EmptyState>
      <EmptyStateHeader>
        <EmptyStateMedia variant="icon">
          <PhUsers aria-hidden="true" />
        </EmptyStateMedia>
        <EmptyStateTitle as-child>
          <h2 id="empty-state-section-heading">No contacts yet</h2>
        </EmptyStateTitle>
        <EmptyStateDescription
          >The empty-state title can be the section heading without adding a
          duplicate hidden heading.</EmptyStateDescription
        >
      </EmptyStateHeader>
    </EmptyState>
  </section>
</template>

Bordered

Set bordered to add a dashed frame around the empty state.

<script setup lang="ts">
import { PhUsers } from '@phosphor-icons/vue';

import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/empty-state';
</script>

<template>
  <EmptyState bordered>
    <EmptyStateHeader>
      <EmptyStateMedia variant="icon">
        <PhUsers aria-hidden="true" />
      </EmptyStateMedia>
      <EmptyStateTitle>No contacts yet</EmptyStateTitle>
      <EmptyStateDescription
        >Add your first contact to get started.</EmptyStateDescription
      >
    </EmptyStateHeader>
  </EmptyState>
</template>

All variants

Compare the icon and default media variants, with and without a frame.

<script setup lang="ts">
import { PhUsers } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
import {
  EmptyState,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
} from '@/components/empty-state';
</script>

<template>
  <div class="nx:flex nx:flex-col nx:gap-6">
    <EmptyState bordered>
      <EmptyStateHeader>
        <EmptyStateMedia variant="icon">
          <PhUsers aria-hidden="true" />
        </EmptyStateMedia>
        <EmptyStateTitle>No contacts yet</EmptyStateTitle>
        <EmptyStateDescription
          >Add your first contact to get started.</EmptyStateDescription
        >
      </EmptyStateHeader>
      <EmptyStateContent>
        <Button>Add contact</Button>
      </EmptyStateContent>
    </EmptyState>

    <EmptyState>
      <EmptyStateHeader>
        <EmptyStateMedia variant="default">
          <PhUsers
            aria-hidden="true"
            class="nx:size-12 nx:text-muted-foreground"
          />
        </EmptyStateMedia>
        <EmptyStateTitle>No results</EmptyStateTitle>
        <EmptyStateDescription
          >Try adjusting your filters.</EmptyStateDescription
        >
      </EmptyStateHeader>
    </EmptyState>
  </div>
</template>

Props

EmptyState

EmptyState props
PropTypeDefaultDescription
borderedbooleanfalse

Adds a dashed frame. Match a bordered skeleton so swapping from loading doesn't shift the layout.

EmptyStateContent

EmptyStateContent adds no props of its own; it accepts the props of the element or primitive it wraps.

EmptyStateDescription

EmptyStateDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

EmptyStateHeader

EmptyStateHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

EmptyStateMedia

EmptyStateMedia props
PropTypeDefaultDescription
variant"default" | "icon" | null"default"No description

EmptyStateTitle

EmptyStateTitle adds no props of its own; it accepts the props of the element or primitive it wraps.