← Empty states
Balloon
<EmptyBalloon /> A lone balloon drifting away. For "page not found".
Nothing here yet
Your message goes here, with an action if you need one.
<EmptyBalloon>
{children}
</EmptyBalloon>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptyBalloonTheme> | — | 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 |
|---|---|---|
balloon | --balloon | #e4573d |
shine | --shine | #ffffff |
string | --string | #8a97a0 |
cloud | --cloud | #e7f1f7 |
bird | --bird | #5b6b78 |
Source files
Copy into components/empty-states/. The shared files are needed once for all empty states.
// <EmptyBalloon>: a lone red balloon drifting up and away, its string trailing, as clouds slide
// past below it and a bird flaps by. For "page not found", missing links and lost things. Animated.
import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-balloon.module.css';
export const emptyBalloonTheme = {
balloon: '#e4573d',
shine: '#ffffff',
string: '#8a97a0',
cloud: '#e7f1f7',
bird: '#5b6b78',
};
export type EmptyBalloonTheme = typeof emptyBalloonTheme;
/** A lone balloon drifting up and away: for "page not found". */
export function EmptyBalloon(props: EmptyStateProps<EmptyBalloonTheme>) {
return (
<EmptyStateFrame
{...props}
defaultTheme={emptyBalloonTheme}
art={
<>
{/* Clouds slide down past the balloon, so it reads as rising */}
<g fill="var(--cloud)">
{[
{ x: 50, w: 34, delay: 0 },
{ x: 186, w: 42, delay: -3.4 },
{ x: 120, w: 26, delay: -6.8 },
].map((c, i) => (
<g key={i} className={styles.cloud} style={{ animationDelay: `${c.delay}s` }}>
<ellipse cx={c.x} cy="0" rx={c.w} ry={c.w * 0.32} />
<ellipse cx={c.x + c.w * 0.3} cy={-c.w * 0.22} rx={c.w * 0.5} ry={c.w * 0.32} />
<ellipse cx={c.x - c.w * 0.35} cy={-c.w * 0.12} rx={c.w * 0.38} ry={c.w * 0.26} />
</g>
))}
</g>
{/* A bird flapping across */}
<g className={styles.bird}>
<path className={styles.flap} d="M-7 0 Q-3.5 -4 0 0 Q3.5 -4 7 0" fill="none" stroke="var(--bird)" strokeWidth="1.6" strokeLinecap="round" />
</g>
{/* Balloon and string: the whole thing drifts and tilts; the string waves on its own */}
<g className={styles.drift}>
<g transform="translate(120 70)">
<g className={styles.tilt}>
<path className={styles.string} d="M0 42 C-8 56 8 70 0 84 C-6 94 4 104 0 114" fill="none" stroke="var(--string)" strokeWidth="1.4" strokeLinecap="round" />
<path d="M-4 40 L4 40 L2 44 L-2 44 Z" fill="var(--balloon)" />
<path d="M0 -38 C22 -38 32 -20 32 -2 C32 20 14 36 0 40 C-14 36 -32 20 -32 -2 C-32 -20 -22 -38 0 -38 Z" fill="var(--balloon)" />
<path d="M-18 -18 C-16 -26 -10 -30 -4 -31" fill="none" stroke="var(--shine)" strokeOpacity="0.6" strokeWidth="4" strokeLinecap="round" />
<circle cx="-20" cy="-8" r="2.4" fill="var(--shine)" opacity="0.5" />
</g>
</g>
</g>
</>
}
/>
);
}
/* The balloon hangs in the frame, drifting and tilting, while clouds slide down past it (one every
3.4s, on a 10.2s loop) so it seems to keep rising. Its string waves from the knot. */
.drift { animation: drift 5s ease-in-out infinite alternate; }
.tilt {
transform-box: fill-box;
transform-origin: 50% 30%;
animation: tilt 3.6s ease-in-out infinite alternate;
}
.string {
transform-box: fill-box;
transform-origin: 50% 0%;
animation: wave 1.8s ease-in-out infinite alternate;
}
.cloud {
opacity: 0;
animation: cloud 10.2s linear infinite;
}
.bird { animation: bird 9s linear infinite; animation-delay: -3s; }
.flap {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: flap 0.4s ease-in-out infinite alternate;
}
@keyframes drift {
from { transform: translate(-8px, 4px); }
to { transform: translate(8px, -4px); }
}
@keyframes tilt {
from { transform: rotate(-6deg); }
to { transform: rotate(5deg); }
}
@keyframes wave {
from { transform: skewX(-8deg); }
to { transform: skewX(8deg); }
}
@keyframes cloud {
0% { transform: translateY(10px); opacity: 0; }
12% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(190px); opacity: 0; }
}
@keyframes bird {
0% { transform: translate(260px, 40px); }
100% { transform: translate(-30px, 26px); }
}
@keyframes flap {
from { transform: scaleY(1); }
to { transform: scaleY(-0.5); }
}
// 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; }
}