← Empty states

Mailbox

<EmptyMailbox /> An open, empty mailbox with a bird on top. For inbox zero.

Animated · Source · Markdown

Nothing here yet

Your message goes here, with an action if you need one.

Theme
Usage
<EmptyMailbox>
  {children}
</EmptyMailbox>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptyMailboxTheme>—Colour overrides. Keys listed under theme tokens.
sizenumber240Illustration width in pixels. Scales down to fit narrow containers.
pausedbooleanfalseFreezes the animation.
classNamestring—Applied to the root element (a centred column).
styleCSSProperties—Merged into the root element style, after theme variables.
childrenReactNode—Your message and actions, shown under the illustration.
Theme keyCSS variableDefault
box--box#9cc9e3
boxShade--box-shade#7fb2d2
inside--inside#3d4a57
flag--flag#e4573d
post--post#9a6b45
grass--grass#b9dca8
bird--bird#f2a65a
birdWing--bird-wing#d9823b
ink--ink#4a3a33
notes--notes#8a97a0
Source files

Copy into components/empty-states/. The shared files are needed once for all empty states.

components/empty-states/empty-mailbox.tsx
// <EmptyMailbox>: a mailbox on a post with its door hanging open and nothing inside, flag down.
// A small bird perched on top bobs its head and chirps. For inbox zero and "no messages". Animated.

import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-mailbox.module.css';

export const emptyMailboxTheme = {
  box: '#9cc9e3',
  boxShade: '#7fb2d2',
  inside: '#3d4a57',
  flag: '#e4573d',
  post: '#9a6b45',
  grass: '#b9dca8',
  bird: '#f2a65a',
  birdWing: '#d9823b',
  ink: '#4a3a33',
  notes: '#8a97a0',
};
export type EmptyMailboxTheme = typeof emptyMailboxTheme;

/** An open, empty mailbox with a bird chirping on top: for inbox zero. */
export function EmptyMailbox(props: EmptyStateProps<EmptyMailboxTheme>) {
  return (
    <EmptyStateFrame
      {...props}
      defaultTheme={emptyMailboxTheme}
      art={
        <>
          <ellipse cx="120" cy="164" rx="86" ry="9" fill="var(--grass)" />
          <g className={styles.tufts} fill="none" stroke="var(--grass)" strokeWidth="2.4" strokeLinecap="round">
            <path d="M58 162 q-2 -8 -6 -12 M62 162 q0 -9 3 -13 M168 163 q1 -9 -2 -13 M173 163 q3 -8 7 -11" />
          </g>

          {/* Post */}
          <rect x="116" y="92" width="12" height="72" rx="2" fill="var(--post)" />
          <rect x="108" y="90" width="28" height="6" rx="2" fill="var(--post)" />

          {/* Body: a rounded tunnel seen from the side, its open end facing left */}
          <path d="M88 50 H150 Q170 50 170 70 V92 H88 Z" fill="var(--box-shade)" />
          <path d="M100 50 H150 Q164 50 166 64" fill="none" stroke="#ffffff" strokeOpacity="0.35" strokeWidth="3" strokeLinecap="round" />

          {/* Flag: down, so there's no mail */}
          <rect x="152" y="74" width="4" height="4" rx="1" fill="var(--inside)" />
          <rect x="154" y="74.5" width="30" height="3" rx="1.5" fill="var(--flag)" />
          <rect x="176" y="77" width="9" height="12" rx="1.5" fill="var(--flag)" />

          {/* Front: the open arch with an empty, dark inside */}
          <path d="M68 92 V68 A18 18 0 0 1 104 68 V92 Z" fill="var(--box)" />
          <path d="M73 91 V69 A13 13 0 0 1 99 69 V91 Z" fill="var(--inside)" />
          <path d="M73 88 H99 V91 H73 Z" fill="#000000" opacity="0.2" />

          {/* Door hanging open from its bottom hinge, swinging slightly */}
          <g className={styles.door}>
            <path d="M68 92 H104 V108 A18 18 0 0 1 68 108 Z" fill="var(--box)" />
            <path d="M80 104 H92" stroke="var(--box-shade)" strokeWidth="3" strokeLinecap="round" />
          </g>

          {/* Bird on the roof */}
          <g transform="translate(132 50)">
            <g className={styles.hop}>
              <path d="M-12 -8 L-20 -4 L-12 -2 Z" fill="var(--bird-wing)" />
              <ellipse cx="-3" cy="-8" rx="11" ry="8" fill="var(--bird)" />
              <path d="M-9 -9 Q-3 -3 3 -8" fill="var(--bird-wing)" />
              <g className={styles.head}>
                <circle cx="7" cy="-16" r="6.5" fill="var(--bird)" />
                <circle cx="9" cy="-17" r="1.3" fill="var(--ink)" />
                <path d="M13 -16.5 L18 -15 L13 -13.5 Z" fill="var(--flag)" />
              </g>
              <path d="M-4 0 V2 M1 0 V2" stroke="var(--ink)" strokeWidth="1.4" strokeLinecap="round" />
            </g>
          </g>

          {/* Chirps */}
          <g fill="var(--notes)">
            <g className={styles.note}>
              <path d="M160 28 v-10 l7 -2 v10" fill="none" stroke="var(--notes)" strokeWidth="1.6" />
              <ellipse cx="158.5" cy="28.5" rx="2.6" ry="2" />
              <ellipse cx="165.5" cy="26.5" rx="2.6" ry="2" />
            </g>
            <g className={`${styles.note} ${styles.note2}`}>
              <path d="M150 20 v-9" fill="none" stroke="var(--notes)" strokeWidth="1.6" />
              <ellipse cx="148.5" cy="20.5" rx="2.4" ry="1.9" />
            </g>
          </g>
        </>
      }
    />
  );
}
components/empty-states/empty-mailbox.module.css
/* One 4s loop: the bird bobs its head, gives a little hop and chirps twice; the door sways. */
.hop { animation: hop 4s ease-in-out infinite; }

.head {
  transform-box: fill-box;
  transform-origin: 30% 90%;
  animation: peck 4s ease-in-out infinite;
}

.note {
  opacity: 0;
  animation: note 4s ease-out infinite;
}
.note2 { animation-delay: 0.35s; }

.door {
  transform-box: fill-box;
  transform-origin: 50% 0%;
  animation: door 3s ease-in-out infinite alternate;
}

.tufts { animation: sway 3.4s ease-in-out infinite alternate; }

@keyframes hop {
  0%, 40%, 52%, 100% { transform: translateY(0); }
  46% { transform: translateY(-6px); }
}
@keyframes peck {
  0%, 12%, 30%, 100% { transform: rotate(0deg); }
  16%, 24% { transform: rotate(-14deg); }
  60%, 66% { transform: rotate(10deg); }
  70% { transform: rotate(0deg); }
}
@keyframes note {
  0%, 52% { transform: translate(0, 6px); opacity: 0; }
  58% { opacity: 1; }
  80% { transform: translate(6px, -6px); opacity: 0; }
  100% { transform: translate(6px, -6px); opacity: 0; }
}
@keyframes door {
  from { transform: rotate(-3deg); }
  to   { transform: rotate(4deg); }
}
@keyframes sway {
  from { transform: translateX(-0.5px); }
  to   { transform: translateX(1px); }
}
components/empty-states/empty-state.tsx
// Shared frame for every empty state: an illustration with your message underneath.
// Empty states are pure server components: no client JavaScript.

import type { CSSProperties, ReactNode } from 'react';
import styles from './empty-state.module.css';

/** Props every empty state accepts. `T` is the illustration's theme (colour tokens). */
export type EmptyStateProps<T> = {
  /** Override any of the illustration's colours. */
  theme?: Partial<T>;
  /** Freeze the animation. */
  paused?: boolean;
  /** Illustration width in pixels (default 240). It scales down to fit narrow containers. */
  size?: number;
  className?: string;
  style?: CSSProperties;
  /** Your message and actions, shown under the illustration. */
  children?: ReactNode;
};

// skyTop → --sky-top, leaf1 → --leaf-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;

type EmptyStateFrameProps<T extends Record<string, string>> = EmptyStateProps<T> & {
  defaultTheme: T;
  /** SVG content drawn in a 240×180 box. */
  art: ReactNode;
};

export function EmptyStateFrame<T extends Record<string, string>>({
  defaultTheme,
  art,
  theme,
  paused,
  size = 240,
  className,
  style,
  children,
}: EmptyStateFrameProps<T>) {
  const vars: Record<string, string> = {};
  for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
    if (value) vars[toVar(key)] = value;
  }
  const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' ');

  return (
    <div className={classes} style={{ ...vars, ...style } as CSSProperties}>
      <svg className={styles.art} width={size} viewBox="0 0 240 180" aria-hidden="true" focusable="false">
        {art}
      </svg>
      {children && <div className={styles.content}>{children}</div>}
    </div>
  );
}
components/empty-states/empty-state.module.css
.root {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 24px;
  text-align: center;
}

.art {
  display: block;
  max-width: 100%;
  height: auto;
  overflow: visible;
}

.content { max-width: 360px; }

.paused * { animation-play-state: paused !important; }

@media (prefers-reduced-motion: reduce) {
  .art * { animation: none !important; }
}