← Empty states

Hammock

<EmptyHammock /> Someone dozing in a hammock. For "all caught up".

Animated · Source · Markdown

Nothing here yet

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

Theme
Usage
<EmptyHammock>
  {children}
</EmptyHammock>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptyHammockTheme>—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
trunk--trunk#9a6b45
fronds--fronds#6cbf6a
frondsShade--fronds-shade#4f9a58
hammock--hammock#e4573d
stripe--stripe#f2c14e
shirt--shirt#9cc9e3
skin--skin#f1c7a3
hat--hat#e8c27a
sand--sand#f0d9a6
Source files

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

components/empty-states/empty-hammock.tsx
// <EmptyHammock>: someone dozing in a hammock strung between two palms, a hat over their face,
// swinging gently while the fronds stir. For "all caught up", inbox zero and "nothing to do".
// Animated.

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

export const emptyHammockTheme = {
  trunk: '#9a6b45',
  fronds: '#6cbf6a',
  frondsShade: '#4f9a58',
  hammock: '#e4573d',
  stripe: '#f2c14e',
  shirt: '#9cc9e3',
  skin: '#f1c7a3',
  hat: '#e8c27a',
  sand: '#f0d9a6',
};
export type EmptyHammockTheme = typeof emptyHammockTheme;

function Palm({ x, lean, flip }: { x: number; lean: number; flip?: boolean }) {
  return (
    <g transform={`translate(${x} 164)${flip ? ' scale(-1 1)' : ''}`}>
      <path d={`M-4 0 Q${lean * 0.4} -60 ${lean} -118 L${lean + 6} -118 Q${lean * 0.4 + 6} -60 4 0 Z`} fill="var(--trunk)" />
      <g transform={`translate(${lean + 3} -118)`}>
        <g className={styles.fronds}>
          <g fill="var(--fronds-shade)">
            <path d="M0 0 C-12 -14 -32 -12 -42 2 C-28 -4 -14 -2 0 0 Z" />
            <path d="M0 0 C14 -16 34 -12 42 2 C28 -4 14 -2 0 0 Z" />
          </g>
          <g fill="var(--fronds)">
            <path d="M0 0 C-6 -18 -24 -26 -38 -18 C-22 -16 -10 -10 0 0 Z" />
            <path d="M0 0 C6 -18 24 -26 38 -18 C22 -16 10 -10 0 0 Z" />
            <path d="M0 0 C-14 4 -28 16 -30 28 C-20 18 -10 10 0 0 Z" />
            <path d="M0 0 C14 4 28 16 30 28 C20 18 10 10 0 0 Z" />
          </g>
          <circle cx="-2" cy="4" r="3" fill="var(--trunk)" />
          <circle cx="3" cy="5" r="3" fill="var(--trunk)" />
        </g>
      </g>
    </g>
  );
}

/** Someone dozing in a hammock between two palms: for "all caught up". */
export function EmptyHammock(props: EmptyStateProps<EmptyHammockTheme>) {
  return (
    <EmptyStateFrame
      {...props}
      defaultTheme={emptyHammockTheme}
      art={
        <>
          <ellipse cx="120" cy="166" rx="104" ry="9" fill="var(--sand)" />
          <Palm x={30} lean={6} />
          <Palm x={210} lean={6} flip />

          {/* The hammock swings from its two tie points; everything in it swings too */}
          <g className={styles.swing}>
            <path d="M40 78 L66 108 M200 78 L174 108" stroke="#8a6a4a" strokeWidth="1.4" />
            <path d="M64 104 Q120 150 176 104 L176 110 Q120 158 64 110 Z" fill="var(--hammock)" />
            <path d="M80 118 Q120 146 160 118" fill="none" stroke="var(--stripe)" strokeWidth="2.4" />

            {/* Sleeper: head on the left under a hat, feet crossed on the right */}
            <path d="M84 112 Q120 132 156 108 L158 102 Q120 124 86 104 Z" fill="var(--shirt)" />
            <circle cx="80" cy="102" r="8" fill="var(--skin)" />
            <g className={styles.breathe}>
              <path d="M68 100 Q80 82 94 98 Z" fill="var(--hat)" />
              <path d="M64 100 H98" stroke="var(--hat)" strokeWidth="3" strokeLinecap="round" />
            </g>
            {/* Legs crossed at the ankle, shoes over the edge */}
            <path d="M150 108 L166 96 M152 112 L168 102" stroke="#5b7a93" strokeWidth="5" strokeLinecap="round" />
            <ellipse cx="169" cy="94" rx="4.5" ry="3" transform="rotate(-30 169 94)" fill="#3d4a57" />
            <ellipse cx="171" cy="100" rx="4.5" ry="3" transform="rotate(-30 171 100)" fill="#3d4a57" />
          </g>

          {/* A drink waiting on the sand */}
          <g transform="translate(120 160)">
            <path d="M-5 0 L-6 -14 H6 L5 0 Z" fill="#f7d26a" opacity="0.9" />
            <path d="M2 -14 L6 -24" stroke="var(--hammock)" strokeWidth="1.4" strokeLinecap="round" />
            <circle cx="-4" cy="-15" r="3" fill="var(--fronds)" />
          </g>

          {/* Zs, small and slow */}
          <g fill="none" stroke="#8a97a0" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
            {[0, 1.6].map((delay, i) => (
              <g key={i} transform={`translate(${92 + i * 6} ${82 - i * 4})`}>
                <path className={styles.z} style={{ animationDelay: `${-delay}s` }} d={`M0 0 H${6 + i * 2} L0 ${6 + i * 2} H${6 + i * 2}`} />
              </g>
            ))}
          </g>
        </>
      }
    />
  );
}
components/empty-states/empty-hammock.module.css
/* The hammock swings like a pendulum from between its tie points; the hat rises and falls with
   breathing; fronds stir; Zs drift up. */
.swing {
  transform-origin: 120px 78px;
  animation: swing 3.6s ease-in-out infinite alternate;
}
.breathe {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: breathe 3s ease-in-out infinite;
}
.fronds {
  transform-box: fill-box;
  transform-origin: 50% 60%;
  animation: stir 4.4s ease-in-out infinite alternate;
}
.z {
  opacity: 0;
  animation: float 3.2s ease-out infinite;
}

@keyframes swing {
  from { transform: rotate(-3deg); }
  to   { transform: rotate(3deg); }
}
@keyframes breathe {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-1.2px); }
}
@keyframes stir {
  from { transform: rotate(-3deg); }
  to   { transform: rotate(3deg); }
}
@keyframes float {
  0%   { transform: translate(0, 0) scale(0.6); opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translate(12px, -30px) scale(1.1); 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; }
}