← Empty states
Mailbox
<EmptyMailbox /> An open, empty mailbox with a bird on top. For inbox zero.
Nothing here yet
Your message goes here, with an action if you need one.
<EmptyMailbox>
{children}
</EmptyMailbox>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptyMailboxTheme> | — | Colour overrides. Keys listed under theme tokens. |
size | number | 240 | Illustration width in pixels. Scales down to fit narrow containers. |
paused | boolean | false | Freezes the animation. |
className | string | — | Applied to the root element (a centred column). |
style | CSSProperties | — | Merged into the root element style, after theme variables. |
children | ReactNode | — | Your message and actions, shown under the illustration. |
| Theme key | CSS variable | Default |
|---|---|---|
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.
// <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>
</>
}
/>
);
}
/* 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); }
}
// 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>
);
}
.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; }
}