← Empty states
Telescope
<EmptyTelescope /> A telescope scanning a quiet night sky. For "no notifications".
Nothing here yet
Your message goes here, with an action if you need one.
<EmptyTelescope>
{children}
</EmptyTelescope>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptyTelescopeTheme> | — | 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 |
|---|---|---|
night | --night | #2a3566 |
stars | --stars | #fff6d8 |
moon | --moon | #f4e7c4 |
hill | --hill | #8cc66a |
hillShade | --hill-shade | #74b35a |
tube | --tube | #e4573d |
brass | --brass | #e8b54d |
tripod | --tripod | #5b4a42 |
Source files
Copy into components/empty-states/. The shared files are needed once for all empty states.
// <EmptyTelescope>: a telescope on a hilltop slowly scanning a round patch of night sky. Stars
// twinkle and now and then a shooting star streaks past. For "no notifications" and "nothing
// new". Animated.
import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-telescope.module.css';
export const emptyTelescopeTheme = {
night: '#2a3566',
stars: '#fff6d8',
moon: '#f4e7c4',
hill: '#8cc66a',
hillShade: '#74b35a',
tube: '#e4573d',
brass: '#e8b54d',
tripod: '#5b4a42',
};
export type EmptyTelescopeTheme = typeof emptyTelescopeTheme;
// Stars inside the sky disc (centre 120,84, radius 72): [x, y, r, twinkle delay].
const STARS: [number, number, number, number][] = [
[78, 52, 1.6, 0],
[102, 34, 1.2, -1.1],
[150, 40, 1.8, -0.4],
[172, 70, 1.2, -1.7],
[92, 82, 1.1, -0.8],
[64, 92, 1.4, -2.2],
[128, 62, 1, -1.4],
[184, 102, 1.4, -0.2],
[118, 24, 1.3, -1.9],
[58, 64, 1, -0.6],
];
/** A telescope scanning a quiet night sky: for "no notifications". */
export function EmptyTelescope(props: EmptyStateProps<EmptyTelescopeTheme>) {
return (
<EmptyStateFrame
{...props}
defaultTheme={emptyTelescopeTheme}
art={
<>
{/* A round patch of night sky */}
<circle cx="120" cy="84" r="72" fill="var(--night)" />
<g fill="var(--stars)">
{STARS.map(([x, y, r, d]) => (
<circle key={`${x}-${y}`} className={styles.star} cx={x} cy={y} r={r} style={{ animationDelay: `${d}s` }} />
))}
</g>
<g transform="translate(160 44)">
<circle r="11" fill="var(--moon)" />
<circle cx="-3" cy="-2" r="2.4" fill="#000" opacity="0.08" />
<circle cx="3.5" cy="3" r="1.8" fill="#000" opacity="0.08" />
</g>
{/* Shooting star: a short streak across the upper left, every 6s */}
<g className={styles.shoot}>
<path d="M0 0 L-22 -9" stroke="var(--stars)" strokeWidth="1.6" strokeLinecap="round" opacity="0.5" />
<path d="M0 0 L-8 -3.3" stroke="var(--stars)" strokeWidth="2" strokeLinecap="round" />
</g>
{/* Hilltop, overlapping the bottom of the sky */}
<path d="M20 160 C50 128 90 118 124 120 C164 122 200 136 222 160 Z" fill="var(--hill)" />
<path d="M124 120 C164 122 200 136 222 160 H170 C176 146 160 130 124 120 Z" fill="var(--hill-shade)" />
<ellipse cx="120" cy="164" rx="104" ry="5" fill="var(--hill-shade)" opacity="0.35" />
{/* Tripod */}
<g stroke="var(--tripod)" strokeWidth="3" strokeLinecap="round">
<path d="M118 104 L100 136 M118 104 L136 136 M118 104 L120 140" />
</g>
{/* Telescope tube on its mount, slowly scanning the sky */}
<g transform="translate(118 102)">
<g className={styles.scan}>
<g transform="rotate(-32)">
<rect x="-22" y="-6" width="52" height="12" rx="3" fill="var(--tube)" />
<rect x="28" y="-8" width="12" height="16" rx="3" fill="var(--tube)" />
<rect x="-30" y="-4" width="9" height="8" rx="2" fill="var(--tripod)" />
<rect x="6" y="-6" width="4" height="12" fill="var(--brass)" />
<rect x="36" y="-8" width="4" height="16" rx="1" fill="var(--brass)" />
<path d="M-14 -3 H20" stroke="#ffffff" strokeOpacity="0.35" strokeWidth="2" strokeLinecap="round" />
</g>
<circle r="4" fill="var(--brass)" />
</g>
</g>
</>
}
/>
);
}
/* The telescope sweeps slowly back and forth across the sky; stars twinkle on their own beats; a
shooting star crosses the upper left once every 6s and is hidden the rest of the time. */
.scan { animation: scan 8s ease-in-out infinite alternate; }
.star {
transform-box: fill-box;
transform-origin: center;
animation: twinkle 2.6s ease-in-out infinite;
}
.shoot {
opacity: 0;
animation: shoot 6s ease-in infinite;
animation-delay: -4s;
}
@keyframes scan {
from { transform: rotate(-10deg); }
to { transform: rotate(12deg); }
}
@keyframes twinkle {
0%, 100% { transform: scale(0.6); opacity: 0.45; }
50% { transform: scale(1); opacity: 1; }
}
@keyframes shoot {
0%, 70% { transform: translate(92px, 40px); opacity: 0; }
74% { opacity: 1; }
86% { opacity: 1; }
92%, 100% { transform: translate(132px, 56px); opacity: 0; }
}
// 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; }
}