← Empty states
Offline
<EmptyOffline /> A tin-can telephone with a slack string. For offline screens.
Nothing here yet
Your message goes here, with an action if you need one.
<EmptyOffline>
{children}
</EmptyOffline>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptyOfflineTheme> | — | 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 |
|---|---|---|
can | --can | #c9d6df |
canShade | --can-shade | #a7b4bd |
label | --label | #9cc9e3 |
labelAlt | --label-alt | #f2c14e |
string | --string | #9a6b45 |
ground | --ground | #e7eef3 |
dots | --dots | #7fb2d2 |
Source files
Copy into components/empty-states/. The shared files are needed once for all empty states.
// <EmptyOffline>: a tin-can telephone whose string has gone slack. Dots leave one can and fade
// out before they reach the other. For offline, "can't connect" and lost-connection screens. Animated.
import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-offline.module.css';
export const emptyOfflineTheme = {
can: '#c9d6df',
canShade: '#a7b4bd',
label: '#9cc9e3',
labelAlt: '#f2c14e',
string: '#9a6b45',
ground: '#e7eef3',
dots: '#7fb2d2',
};
export type EmptyOfflineTheme = typeof emptyOfflineTheme;
function Can({ x, label, className }: { x: number; label: string; className?: string }) {
return (
<g className={className}>
<path d={`M${x} 104 V146 a18 5 0 0 0 36 0 V104 Z`} fill="var(--can)" />
<path d={`M${x} 116 H${x + 36} V132 H${x} Z`} fill={label} />
<g fill="none" stroke="var(--can-shade)" strokeWidth="1.4">
<path d={`M${x} 110 a18 5 0 0 0 36 0`} />
<path d={`M${x} 138 a18 5 0 0 0 36 0`} />
</g>
<ellipse cx={x + 18} cy="104" rx="18" ry="5" fill="var(--can-shade)" />
<ellipse cx={x + 18} cy="104.6" rx="15" ry="3.6" fill="#ffffff" opacity="0.25" />
</g>
);
}
/** A tin-can telephone with a slack string: for offline screens. */
export function EmptyOffline(props: EmptyStateProps<EmptyOfflineTheme>) {
return (
<EmptyStateFrame
{...props}
defaultTheme={emptyOfflineTheme}
art={
<>
<ellipse cx="120" cy="154" rx="96" ry="10" fill="var(--ground)" />
{/* Slack string: sagging to the ground and swaying, its ends fixed to the cans */}
<g className={styles.string}>
<path d="M72 140 C96 166 144 166 168 140" fill="none" stroke="var(--string)" strokeWidth="2" strokeLinecap="round" />
</g>
<Can x={36} label="var(--label)" className={styles.speaker} />
<Can x={168} label="var(--label-alt)" />
{/* Dots leave the left can and fade before reaching the right one */}
<g fill="var(--dots)">
{[0, 1, 2].map(i => (
<circle key={i} className={styles.dot} style={{ animationDelay: `${i * 0.35}s` }} cx="58" cy="90" r="4" />
))}
</g>
{/* A question mark hangs over the right can */}
<g className={styles.question} fill="none" stroke="var(--can-shade)" strokeWidth="3" strokeLinecap="round">
<path d="M180 74 q0 -8 6 -8 q6 0 6 6 q0 5 -6 7 v4" />
<circle cx="186" cy="90" r="0.6" />
</g>
</>
}
/>
);
}
/* One 3.6s loop: the left can tips forward as three dots rise from it and fade halfway across;
a question mark tilts over the right can; the slack string sways from its two fixed ends. */
.dot {
opacity: 0;
animation: dot 3.6s ease-out infinite;
}
.question {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: wonder 3.6s ease-in-out infinite;
}
/* The left can tips toward the other as it "speaks". */
.speaker {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: speak 3.6s ease-in-out infinite;
}
/* Scaling about the line between the two ends keeps them pinned to the cans. */
.string {
transform-origin: 120px 140px;
animation: slack 3.6s ease-in-out infinite alternate;
}
@keyframes dot {
0% { transform: translate(0, 6px); opacity: 0; }
12% { opacity: 1; }
45% { transform: translate(44px, -18px); opacity: 0.8; }
65% { transform: translate(60px, -14px); opacity: 0; }
100% { transform: translate(60px, -14px); opacity: 0; }
}
@keyframes wonder {
0%, 40% { transform: rotate(0deg) translateY(4px); opacity: 0; }
55% { transform: rotate(-8deg) translateY(0); opacity: 1; }
75% { transform: rotate(6deg) translateY(0); opacity: 1; }
92%, 100% { transform: rotate(0deg) translateY(-2px); opacity: 0; }
}
@keyframes speak {
0%, 40%, 100% { transform: rotate(0deg); }
8% { transform: rotate(4deg); }
16% { transform: rotate(1deg); }
24% { transform: rotate(4deg); }
}
@keyframes slack {
from { transform: scaleY(0.82); }
to { transform: scaleY(1.08); }
}
// 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; }
}