← Empty states

Nap

<EmptyNap /> A cat asleep on a cushion. For "nothing here yet".

Animated · Source · Markdown

Nothing here yet

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

Theme
Usage
<EmptyNap>
  {children}
</EmptyNap>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptyNapTheme>—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
cat--cat#f2a65a
stripes--stripes#d9823b
cushion--cushion#9cc9e3
cushionShade--cushion-shade#7fb2d2
blush--blush#f7b7c8
ink--ink#4a3a33
zzz--zzz#8a97a0
Source files

Copy into components/empty-states/. The shared files are needed once for all empty states.

components/empty-states/empty-nap.tsx
// <EmptyNap>: a cat curled up asleep on a cushion, breathing slowly, with Zs drifting up.
// For "nothing here yet" screens. Animated.

import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-nap.module.css';

export const emptyNapTheme = {
  cat: '#f2a65a',
  stripes: '#d9823b',
  cushion: '#9cc9e3',
  cushionShade: '#7fb2d2',
  blush: '#f7b7c8',
  ink: '#4a3a33',
  zzz: '#8a97a0',
};
export type EmptyNapTheme = typeof emptyNapTheme;

/** A cat curled up asleep on a cushion, for "nothing here yet" screens. */
export function EmptyNap(props: EmptyStateProps<EmptyNapTheme>) {
  return (
    <EmptyStateFrame
      {...props}
      defaultTheme={emptyNapTheme}
      art={
        <>
          {/* Cushion */}
          <ellipse cx="120" cy="150" rx="84" ry="16" fill="var(--cushion-shade)" />
          <ellipse cx="120" cy="143" rx="84" ry="16" fill="var(--cushion)" />
          <circle cx="44" cy="141" r="5" fill="var(--cushion-shade)" />
          <circle cx="196" cy="141" r="5" fill="var(--cushion-shade)" />

          {/* Tail curls around the front, hinged at the hip */}
          <path
            className={styles.tail}
            d="M164 128 C186 132 188 150 160 152 C130 154 104 152 92 148"
            fill="none"
            stroke="var(--cat)"
            strokeWidth="11"
            strokeLinecap="round"
          />

          <g className={styles.breathe}>
            {/* Body */}
            <ellipse cx="128" cy="122" rx="50" ry="26" fill="var(--cat)" />
            <g fill="none" stroke="var(--stripes)" strokeWidth="4" strokeLinecap="round">
              <path d="M118 98 C122 104 122 110 118 116" />
              <path d="M134 97 C138 103 138 109 134 115" />
              <path d="M150 100 C154 106 154 112 150 118" />
            </g>
            {/* Head resting on the front paws */}
            <path className={styles.ear} d="M70 98 L74 78 L88 92 Z" fill="var(--cat)" />
            <path d="M96 94 L106 76 L112 96 Z" fill="var(--cat)" />
            <path d="M74 95 L76 84 L84 92 Z" fill="var(--blush)" />
            <ellipse cx="90" cy="114" rx="26" ry="22" fill="var(--cat)" />
            <ellipse cx="86" cy="134" rx="16" ry="7" fill="var(--cat)" />
            <g fill="none" stroke="var(--ink)" strokeWidth="2.2" strokeLinecap="round">
              <path d="M76 112 Q80 116 84 112" />
              <path d="M94 112 Q98 116 102 112" />
              <path d="M87 121 Q89 123 91 121" />
            </g>
            <ellipse cx="74" cy="120" rx="5" ry="3" fill="var(--blush)" opacity="0.8" />
            <ellipse cx="105" cy="120" rx="5" ry="3" fill="var(--blush)" opacity="0.8" />
          </g>

          {/* Zs drifting up from the head */}
          <g fill="none" stroke="var(--zzz)" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
            {[0, 1.2, 2.4].map((delay, i) => (
              // Position on the group; the path's own transform is the CSS float.
              <g key={i} transform={`translate(${108 + i * 4} ${78 - i * 2})`}>
                <path
                  className={styles.z}
                  style={{ animationDelay: `${-delay}s` }}
                  d={`M0 0 H${8 + i * 2} L0 ${8 + i * 2} H${8 + i * 2}`}
                />
              </g>
            ))}
          </g>

          <g fill="var(--zzz)">
            <path className={styles.star} d="M188 40 l2 5 5 2 -5 2 -2 5 -2 -5 -5 -2 5 -2 Z" />
            <path className={styles.star} style={{ animationDelay: '-1.2s' }} d="M206 70 l1.4 3.5 3.5 1.4 -3.5 1.4 -1.4 3.5 -1.4 -3.5 -3.5 -1.4 3.5 -1.4 Z" />
          </g>
        </>
      }
    />
  );
}
components/empty-states/empty-nap.module.css
/* The cat breathes from its belly, its tail swishes, and Zs drift up and fade. */
.breathe {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: breathe 2.6s ease-in-out infinite;
}
.tail {
  transform-box: fill-box;
  transform-origin: 0% 50%;
  animation: swish 3.2s ease-in-out infinite;
}
.ear {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: twitch 7s ease-in-out infinite;
}
.z {
  animation: float 3.6s ease-out infinite;
  opacity: 0;
}
.star { animation: twinkle 2.4s ease-in-out infinite alternate; }

@keyframes breathe {
  0%, 100% { transform: scaleY(1); }
  50% { transform: scaleY(1.05); }
}
@keyframes swish {
  0%, 100% { transform: rotate(0deg); }
  50% { transform: rotate(-12deg); }
}
@keyframes twitch {
  0%, 92%, 100% { transform: rotate(0deg); }
  95% { transform: rotate(-14deg); }
}
@keyframes float {
  0% { transform: translate(0, 0) scale(0.6); opacity: 0; }
  20% { opacity: 1; }
  100% { transform: translate(18px, -46px) scale(1.15); opacity: 0; }
}
@keyframes twinkle { from { opacity: 0.25; } to { opacity: 1; } }
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; }
}