← Empty states

Telescope

<EmptyTelescope /> A telescope scanning a quiet night sky. For "no notifications".

Animated · Source · Markdown

Nothing here yet

Your message goes here, with an action if you need one.

Theme
Usage
<EmptyTelescope>
  {children}
</EmptyTelescope>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptyTelescopeTheme>—Colour overrides. Keys listed under theme tokens.
sizenumber240Illustration width in pixels. Scales down to fit narrow containers.
pausedbooleanfalseFreezes the animation.
classNamestring—Applied to the root element (a centred column).
styleCSSProperties—Merged into the root element style, after theme variables.
childrenReactNode—Your message and actions, shown under the illustration.
Theme keyCSS variableDefault
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.

components/empty-states/empty-telescope.tsx
// <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>
        </>
      }
    />
  );
}
components/empty-states/empty-telescope.module.css
/* 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; }
}
components/empty-states/empty-state.tsx
// 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>
  );
}
components/empty-states/empty-state.module.css
.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; }
}