← Empty states
Nap
<EmptyNap /> A cat asleep on a cushion. For "nothing here yet".
Nothing here yet
Your message goes here, with an action if you need one.
<EmptyNap>
{children}
</EmptyNap>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptyNapTheme> | — | 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 |
|---|---|---|
cat | --cat | #f2a65a |
stripes | --stripes | #d9823b |
cushion | --cushion | #9cc9e3 |
cushionShade | --cushion-shade | #7fb2d2 |
blush | --blush | #f7b7c8 |
ink | --ink | #4a3a33 |
zzz | --zzz | #8a97a0 |
Source files
Copy into components/empty-states/. The shared files are needed once for all empty states.
// <EmptyNap>: a cat curled up asleep on a cushion, breathing slowly, with Zs drifting up.
// For "nothing here yet" screens. Animated.
import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-nap.module.css';
export const emptyNapTheme = {
cat: '#f2a65a',
stripes: '#d9823b',
cushion: '#9cc9e3',
cushionShade: '#7fb2d2',
blush: '#f7b7c8',
ink: '#4a3a33',
zzz: '#8a97a0',
};
export type EmptyNapTheme = typeof emptyNapTheme;
/** A cat curled up asleep on a cushion, for "nothing here yet" screens. */
export function EmptyNap(props: EmptyStateProps<EmptyNapTheme>) {
return (
<EmptyStateFrame
{...props}
defaultTheme={emptyNapTheme}
art={
<>
{/* Cushion */}
<ellipse cx="120" cy="150" rx="84" ry="16" fill="var(--cushion-shade)" />
<ellipse cx="120" cy="143" rx="84" ry="16" fill="var(--cushion)" />
<circle cx="44" cy="141" r="5" fill="var(--cushion-shade)" />
<circle cx="196" cy="141" r="5" fill="var(--cushion-shade)" />
{/* Tail curls around the front, hinged at the hip */}
<path
className={styles.tail}
d="M164 128 C186 132 188 150 160 152 C130 154 104 152 92 148"
fill="none"
stroke="var(--cat)"
strokeWidth="11"
strokeLinecap="round"
/>
<g className={styles.breathe}>
{/* Body */}
<ellipse cx="128" cy="122" rx="50" ry="26" fill="var(--cat)" />
<g fill="none" stroke="var(--stripes)" strokeWidth="4" strokeLinecap="round">
<path d="M118 98 C122 104 122 110 118 116" />
<path d="M134 97 C138 103 138 109 134 115" />
<path d="M150 100 C154 106 154 112 150 118" />
</g>
{/* Head resting on the front paws */}
<path className={styles.ear} d="M70 98 L74 78 L88 92 Z" fill="var(--cat)" />
<path d="M96 94 L106 76 L112 96 Z" fill="var(--cat)" />
<path d="M74 95 L76 84 L84 92 Z" fill="var(--blush)" />
<ellipse cx="90" cy="114" rx="26" ry="22" fill="var(--cat)" />
<ellipse cx="86" cy="134" rx="16" ry="7" fill="var(--cat)" />
<g fill="none" stroke="var(--ink)" strokeWidth="2.2" strokeLinecap="round">
<path d="M76 112 Q80 116 84 112" />
<path d="M94 112 Q98 116 102 112" />
<path d="M87 121 Q89 123 91 121" />
</g>
<ellipse cx="74" cy="120" rx="5" ry="3" fill="var(--blush)" opacity="0.8" />
<ellipse cx="105" cy="120" rx="5" ry="3" fill="var(--blush)" opacity="0.8" />
</g>
{/* Zs drifting up from the head */}
<g fill="none" stroke="var(--zzz)" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{[0, 1.2, 2.4].map((delay, i) => (
// Position on the group; the path's own transform is the CSS float.
<g key={i} transform={`translate(${108 + i * 4} ${78 - i * 2})`}>
<path
className={styles.z}
style={{ animationDelay: `${-delay}s` }}
d={`M0 0 H${8 + i * 2} L0 ${8 + i * 2} H${8 + i * 2}`}
/>
</g>
))}
</g>
<g fill="var(--zzz)">
<path className={styles.star} d="M188 40 l2 5 5 2 -5 2 -2 5 -2 -5 -5 -2 5 -2 Z" />
<path className={styles.star} style={{ animationDelay: '-1.2s' }} d="M206 70 l1.4 3.5 3.5 1.4 -3.5 1.4 -1.4 3.5 -1.4 -3.5 -3.5 -1.4 3.5 -1.4 Z" />
</g>
</>
}
/>
);
}
/* The cat breathes from its belly, its tail swishes, and Zs drift up and fade. */
.breathe {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: breathe 2.6s ease-in-out infinite;
}
.tail {
transform-box: fill-box;
transform-origin: 0% 50%;
animation: swish 3.2s ease-in-out infinite;
}
.ear {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: twitch 7s ease-in-out infinite;
}
.z {
animation: float 3.6s ease-out infinite;
opacity: 0;
}
.star { animation: twinkle 2.4s ease-in-out infinite alternate; }
@keyframes breathe {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.05); }
}
@keyframes swish {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(-12deg); }
}
@keyframes twitch {
0%, 92%, 100% { transform: rotate(0deg); }
95% { transform: rotate(-14deg); }
}
@keyframes float {
0% { transform: translate(0, 0) scale(0.6); opacity: 0; }
20% { opacity: 1; }
100% { transform: translate(18px, -46px) scale(1.15); opacity: 0; }
}
@keyframes twinkle { from { opacity: 0.25; } to { opacity: 1; } }
// 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; }
}