Skip to content
  1. Feedback
  2. Empty page

Empty page

The full-page state of a register that was never used: the skeleton of the future table behind, an invitation card on top, and what the register brings below.

import { Button } from '@/components/button'
import { EmptyPage } from '@/components/empty-page'
import { TextLink } from '@/components/text'
import { ArrowUpRightIcon } from '@heroicons/react/16/solid'
import { BoltIcon, ClockIcon, ShieldCheckIcon } from '@heroicons/react/20/solid'
import { TransferIllustration } from './transfer-illustration'

const guarantees = [
  'Transfers are processed within 1 to 3 business days',
  'All transfers are encrypted and processed securely',
  'Monitor transfer status in real time',
]

const benefits = [
  { title: 'Fast processing', description: 'One to three business days, depending on the channel.', icon: BoltIcon },
  { title: 'Secure and safe', description: 'Encrypted, and sent through verified channels.', icon: ShieldCheckIcon },
  { title: 'Track status', description: 'Every attempt, from created to paid out.', icon: ClockIcon },
]

export default function TransfersPage() {
  return (
    <EmptyPage
      title="Start your first transfer"
      description="Withdraw funds to your beneficiaries quickly and securely. Get started by initiating your first transfer."
      guarantees={guarantees}
      benefitsTitle="Everything you get with Transfers"
      benefits={benefits}
      illustration={<TransferIllustration className="h-44 w-auto lg:h-56" />}
      actions={
        <>
          <Button color="accent" href="/transfers/new">
            <ArrowUpRightIcon data-slot="icon" />
            Initiate transfer
          </Button>
          <TextLink action external href="https://docs.example.com/transfers">
            View documentation
          </TextLink>
        </>
      }
    />
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import EmptyPage from '@/components/empty-page.vue'
import TextLink from '@/components/text-link.vue'
import TransferIllustration from './transfer-illustration.vue'
import { ArrowUpRightIcon } from '@heroicons/vue/16/solid'
import { BoltIcon, ClockIcon, ShieldCheckIcon } from '@heroicons/vue/20/solid'

const guarantees = [
  'Transfers are processed within 1 to 3 business days',
  'All transfers are encrypted and processed securely',
  'Monitor transfer status in real time',
]

const benefits = [
  { title: 'Fast processing', description: 'One to three business days, depending on the channel.', icon: BoltIcon },
  { title: 'Secure and safe', description: 'Encrypted, and sent through verified channels.', icon: ShieldCheckIcon },
  { title: 'Track status', description: 'Every attempt, from created to paid out.', icon: ClockIcon },
]

</script>

<template>
  <EmptyPage
    title="Start your first transfer"
    description="Withdraw funds to your beneficiaries quickly and securely. Get started by initiating your first transfer."
    :guarantees="guarantees"
    benefits-title="Everything you get with Transfers"
    :benefits="benefits"
  >
    <template #illustration>
      <TransferIllustration class="h-44 w-auto lg:h-56" />
    </template>
    <template #actions>
      <Button color="accent" href="/transfers/new">
        <ArrowUpRightIcon data-slot="icon" />
        Initiate transfer
      </Button>
      <TextLink action external href="https://docs.example.com/transfers">View documentation</TextLink>
    </template>
  </EmptyPage>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import { CheckCircleIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { GhostTable } from './ghost-table'

export function EmptyPage({
  title,
  description,
  guarantees,
  benefitsTitle,
  benefitsDescription,
  benefits,
  actions,
  illustration,
  level = 2,
  className,
  ...props
}: {
  title: string
  description: string
  guarantees: string[]
  benefitsTitle: string
  benefitsDescription?: string
  benefits: { title: string; description: string; icon: React.ComponentType<{ className?: string }> }[]
  actions?: React.ReactNode
  illustration?: React.ReactNode
  level?: 1 | 2 | 3 | 4 | 5 | 6
} & React.ComponentPropsWithoutRef<'div'>) {
  let Heading = `h${level}` as const

  return (
    <div {...props} className={className}>
      <div className="relative isolate overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
        <GhostTable className="absolute inset-0 -z-10" />

        <div className="px-4 sm:px-10 lg:px-14">
          <div className="mx-auto w-full max-w-4xl bg-white px-6 py-10 shadow-xl ring-1 ring-zinc-950/5 sm:px-10 sm:py-14 dark:bg-zinc-800 dark:shadow-none dark:ring-white/10">
            <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:gap-14">
              <div className="min-w-0">
                <Heading className="max-w-[35ch] text-3xl font-semibold tracking-tight text-balance text-zinc-950 sm:text-4xl dark:text-white">
                  {title}
                </Heading>
                <p className="mt-4 max-w-[56ch] text-base/7 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
                  {description}
                </p>
                <ul role="list" className="mt-7 space-y-2.5 border-t border-zinc-950/5 pt-7 dark:border-white/5">
                  {guarantees.map((guarantee) => (
                    <li
                      key={guarantee}
                      className="flex items-start gap-2 text-base/6 text-zinc-600 sm:text-sm/6 dark:text-zinc-400"
                    >
                      <CheckCircleIcon className="size-4 h-lh shrink-0 fill-accent-600 dark:fill-accent-400" />
                      {guarantee}
                    </li>
                  ))}
                </ul>
                {actions && <div className="mt-8 flex flex-wrap items-center gap-x-5 gap-y-3">{actions}</div>}
              </div>

              {illustration && <div className="max-sm:hidden">{illustration}</div>}
            </div>
          </div>
        </div>
      </div>

      <section className="mt-12">
        <Heading className="text-lg/7 font-semibold text-zinc-950 sm:text-base/6 dark:text-white">{benefitsTitle}</Heading>
        {benefitsDescription && (
          <p className="mt-1 max-w-[56ch] text-base/6 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
            {benefitsDescription}
          </p>
        )}
        <dl className="mt-5 grid grid-cols-1 lg:flex">
          {benefits.map((benefit) => (
            <div
              key={benefit.title}
              className={clsx(
                'min-w-0 border-zinc-950/5 max-lg:py-5 max-lg:not-first:border-t lg:flex-1 lg:not-first:border-l lg:not-first:pl-6 lg:not-last:pr-6 dark:border-white/5'
              )}
            >
              <dt className="flex items-center gap-2 text-base/6 font-semibold text-zinc-950 sm:text-sm/6 dark:text-white">
                <benefit.icon className="size-5 shrink-0 fill-accent-600 dark:fill-accent-400" />
                {benefit.title}
              </dt>
              <dd className="mt-1.5 text-base/6 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
                {benefit.description}
              </dd>
            </div>
          ))}
        </dl>
      </section>
    </div>
  )
}
<script setup lang="ts">
import type { Component } from 'vue'
import { CheckCircleIcon } from '@heroicons/vue/16/solid'
import GhostTable from './ghost-table.vue'

withDefaults(defineProps<{
  title: string
  description: string
  guarantees: string[]
  benefitsTitle: string
  benefitsDescription?: string
  benefits: { title: string; description: string; icon: Component }[]
  level?: 1 | 2 | 3 | 4 | 5 | 6
}>(), { level: 2 })
</script>

<template>
  <div>
    <div class="relative isolate overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
      <GhostTable class="absolute inset-0 -z-10" />

      <div class="px-4 sm:px-10 lg:px-14">
        <div
          class="mx-auto w-full max-w-4xl bg-white px-6 py-10 shadow-xl ring-1 ring-zinc-950/5 sm:px-10 sm:py-14 dark:bg-zinc-800 dark:shadow-none dark:ring-white/10"
        >
          <div class="grid gap-10 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:gap-14">
            <div class="min-w-0">
              <component :is="`h${level}`" class="max-w-[35ch] text-3xl font-semibold tracking-tight text-balance text-zinc-950 sm:text-4xl dark:text-white">
                {{ title }}
              </component>
              <p class="mt-4 max-w-[56ch] text-base/7 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
                {{ description }}
              </p>
              <ul role="list" class="mt-7 space-y-2.5 border-t border-zinc-950/5 pt-7 dark:border-white/5">
                <li
                  v-for="guarantee in guarantees"
                  :key="guarantee"
                  class="flex items-start gap-2 text-base/6 text-zinc-600 sm:text-sm/6 dark:text-zinc-400"
                >
                  <CheckCircleIcon class="size-4 h-lh shrink-0 fill-accent-600 dark:fill-accent-400" />
                  {{ guarantee }}
                </li>
              </ul>
              <div v-if="$slots.actions" class="mt-8 flex flex-wrap items-center gap-x-5 gap-y-3">
                <slot name="actions" />
              </div>
            </div>

            <div v-if="$slots.illustration" class="max-sm:hidden">
              <slot name="illustration" />
            </div>
          </div>
        </div>
      </div>
    </div>

    <section class="mt-12">
      <component :is="`h${level}`" class="text-lg/7 font-semibold text-zinc-950 sm:text-base/6 dark:text-white">
        {{ benefitsTitle }}
      </component>
      <p
        v-if="benefitsDescription"
        class="mt-1 max-w-[56ch] text-base/6 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400"
      >
        {{ benefitsDescription }}
      </p>
      <dl class="mt-5 grid grid-cols-1 lg:flex">
        <div
          v-for="benefit in benefits"
          :key="benefit.title"
          class="min-w-0 border-zinc-950/5 max-lg:py-5 max-lg:not-first:border-t lg:flex-1 lg:not-first:border-l lg:not-first:pl-6 lg:not-last:pr-6 dark:border-white/5"
        >
          <dt class="flex items-center gap-2 text-base/6 font-semibold text-zinc-950 sm:text-sm/6 dark:text-white">
            <component :is="benefit.icon" class="size-5 shrink-0 fill-accent-600 dark:fill-accent-400" />
            {{ benefit.title }}
          </dt>
          <dd class="mt-1.5 text-base/6 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
            {{ benefit.description }}
          </dd>
        </div>
      </dl>
    </section>
  </div>
</template>

Component API

PropDefaultDescription
EmptyPagerenders a <div> element
title-The invitation, as a heading.
description-One or two sentences on what the register does.
guarantees-Reassurance lines under the description, checked in the accent color. No trailing period.
benefitsTitle-Heading of the band below the card.
benefitsDescription-Optional line under that heading.
benefits-Array of `{ title, description, icon }`, rendered as columns from lg.
actions-Buttons and links under the guarantees.
illustration-Drawn beside the text from sm up.
level2Heading level of the title and of the benefits heading.
GhostTablerenders a <div> element
header-Width classes of the header bars, one per column.
rows-Width classes of each row of bars. Twelve rows by default, faded out at the bottom.
PropDefaultDescription
EmptyPagerenders a <div> element
title-The invitation, as a heading.
description-One or two sentences on what the register does.
guarantees-Reassurance lines under the description, checked in the accent color. No trailing period.
benefitsTitle-Heading of the band below the card.
benefitsDescription-Optional line under that heading.
benefits-Array of `{ title, description, icon }`, rendered as columns from lg.
#actions-Slot for buttons and links under the guarantees.
#illustration-Slot drawn beside the text from sm up.
level2Heading level of the title and of the benefits heading.
GhostTablerenders a <div> element
header-Width classes of the header bars, one per column.
rows-Width classes of each row of bars. Twelve rows by default, faded out at the bottom.

Examples

Basic example

Reserve it for the first visit. Once a filter empties the list, keep the table and show an Empty state instead: the screen has no reason to reintroduce itself on every search. The main button takes color="accent" so it follows the page accent. The component spans the page, so open the demo to see it at its real width:

import { Button } from '@/components/button'
import { EmptyPage } from '@/components/empty-page'
import { TextLink } from '@/components/text'
import { ArrowUpRightIcon } from '@heroicons/react/16/solid'
import { BoltIcon, ClockIcon, ShieldCheckIcon } from '@heroicons/react/20/solid'
import { TransferIllustration } from './transfer-illustration'

const guarantees = [
  'Transfers are processed within 1 to 3 business days',
  'All transfers are encrypted and processed securely',
  'Monitor transfer status in real time',
]

const benefits = [
  { title: 'Fast processing', description: 'One to three business days, depending on the channel.', icon: BoltIcon },
  { title: 'Secure and safe', description: 'Encrypted, and sent through verified channels.', icon: ShieldCheckIcon },
  { title: 'Track status', description: 'Every attempt, from created to paid out.', icon: ClockIcon },
]

export default function TransfersPage() {
  return (
    <EmptyPage
      title="Start your first transfer"
      description="Withdraw funds to your beneficiaries quickly and securely. Get started by initiating your first transfer."
      guarantees={guarantees}
      benefitsTitle="Everything you get with Transfers"
      benefits={benefits}
      illustration={<TransferIllustration className="h-44 w-auto lg:h-56" />}
      actions={
        <>
          <Button color="accent" href="/transfers/new">
            <ArrowUpRightIcon data-slot="icon" />
            Initiate transfer
          </Button>
          <TextLink action external href="https://docs.example.com/transfers">
            View documentation
          </TextLink>
        </>
      }
    />
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import EmptyPage from '@/components/empty-page.vue'
import TextLink from '@/components/text-link.vue'
import TransferIllustration from './transfer-illustration.vue'
import { ArrowUpRightIcon } from '@heroicons/vue/16/solid'
import { BoltIcon, ClockIcon, ShieldCheckIcon } from '@heroicons/vue/20/solid'

const guarantees = [
  'Transfers are processed within 1 to 3 business days',
  'All transfers are encrypted and processed securely',
  'Monitor transfer status in real time',
]

const benefits = [
  { title: 'Fast processing', description: 'One to three business days, depending on the channel.', icon: BoltIcon },
  { title: 'Secure and safe', description: 'Encrypted, and sent through verified channels.', icon: ShieldCheckIcon },
  { title: 'Track status', description: 'Every attempt, from created to paid out.', icon: ClockIcon },
]

</script>

<template>
  <EmptyPage
    title="Start your first transfer"
    description="Withdraw funds to your beneficiaries quickly and securely. Get started by initiating your first transfer."
    :guarantees="guarantees"
    benefits-title="Everything you get with Transfers"
    :benefits="benefits"
  >
    <template #illustration>
      <TransferIllustration class="h-44 w-auto lg:h-56" />
    </template>
    <template #actions>
      <Button color="accent" href="/transfers/new">
        <ArrowUpRightIcon data-slot="icon" />
        Initiate transfer
      </Button>
      <TextLink action external href="https://docs.example.com/transfers">View documentation</TextLink>
    </template>
  </EmptyPage>
</template>

Illustration

Draw the illustration inside an IllustrationFrame: it brings the halo, the shadow and the sparkles, you add the paths. Every path reads the accent-* tokens, so it recolors with the page:

import { IllustrationFrame } from '@/components/illustration-frame'

export function TransferIllustration(props: React.ComponentPropsWithoutRef<'svg'>) {
  return (
    <IllustrationFrame {...props}>
      <rect className="fill-accent-200 dark:fill-accent-900" x="70" y="110" width="128" height="84" rx="14" />
      <rect className="fill-accent-600" x="196" y="132" width="98" height="64" rx="12" />
      <path className="stroke-accent-600 dark:stroke-accent-400" d="M150 172c24 0 30 30 56 30" strokeWidth="6" strokeLinecap="round" fill="none" />
      <circle className="fill-amber-400" cx="206" cy="202" r="14" />
    </IllustrationFrame>
  )
}
<script setup lang="ts">
import IllustrationFrame from '@/components/illustration-frame.vue'
</script>

<template>
  <IllustrationFrame>
    <rect class="fill-accent-200 dark:fill-accent-900" x="70" y="110" width="128" height="84" rx="14" />
    <rect class="fill-accent-600" x="196" y="132" width="98" height="64" rx="12" />
    <path class="stroke-accent-600 dark:stroke-accent-400" d="M150 172c24 0 30 30 56 30" stroke-width="6" stroke-linecap="round" fill="none" />
    <circle class="fill-amber-400" cx="206" cy="202" r="14" />
  </IllustrationFrame>
</template>

Page accent

Set an accent-* utility on the page root: guarantees, benefit icons, the illustration and the accented button all recolor together. Without it, the accent falls back to blue:

export default function TransfersPage() {
  return (
    <div className="accent-emerald">
      <EmptyPage … />
    </div>
  )
}
<template>
  <div class="accent-emerald">
    <EmptyPage … />
  </div>
</template>

The tokens are not part of Tailwind: declare them once in your stylesheet, next to your @theme, with one @utility per palette you use. inline matters, the variable must resolve where the class is set:

@theme inline {
  --color-accent-200: var(--accent-200, var(--color-blue-200));
  --color-accent-300: var(--accent-300, var(--color-blue-300));
  --color-accent-400: var(--accent-400, var(--color-blue-400));
  --color-accent-500: var(--accent-500, var(--color-blue-500));
  --color-accent-600: var(--accent-600, var(--color-blue-600));
  --color-accent-700: var(--accent-700, var(--color-blue-700));
  --color-accent-900: var(--accent-900, var(--color-blue-900));
}

@utility accent-emerald {
  --accent-200: var(--color-emerald-200);
  --accent-300: var(--color-emerald-300);
  --accent-400: var(--color-emerald-400);
  --accent-500: var(--color-emerald-500);
  --accent-600: var(--color-emerald-600);
  --accent-700: var(--color-emerald-700);
  --accent-900: var(--color-emerald-900);
}

Ghost table

The skeleton behind the card is its own component. It is decorative, hidden from assistive tech, and fades out at the bottom so its row count never has to match the height:

<div className="relative h-64 overflow-hidden rounded-2xl ring-1 ring-zinc-950/5">
  <GhostTable className="absolute inset-0" />
</div>
<template>
  <div class="relative h-64 overflow-hidden rounded-2xl ring-1 ring-zinc-950/5">
    <GhostTable class="absolute inset-0" />
  </div>
</template>