← Empty states

Offline

<EmptyOffline /> A tin-can telephone with a slack string. For offline screens.

Animated · Source · Markdown

Nothing here yet

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

Theme
Usage
<EmptyOffline>
  {children}
</EmptyOffline>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptyOfflineTheme>—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
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.

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