← Empty states

Balloon

<EmptyBalloon /> A lone balloon drifting away. For "page not found".

Animated · Source · Markdown

Nothing here yet

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

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

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