← Empty states

Snail

<EmptySnail /> A snail with a flag, crawling along at its own pace. For "coming soon".

Animated · Source · Markdown

Nothing here yet

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

Theme
Usage
<EmptySnail>
  {children}
</EmptySnail>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptySnailTheme>—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
body--body#e9c79b
bodyShade--body-shade#d4a873
shell--shell#c9824a
shellLine--shell-line#9a5a2e
flag--flag#5b9bd5
pole--pole#7a6a5e
ground--ground#b9dca8
pebbles--pebbles#c9c3b8
trail--trail#ffffff
eyes--eyes#3d3a3a
Source files

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

components/empty-states/empty-snail.tsx
// <EmptySnail>: a snail carrying a little flag on its shell, crawling along at its own pace. Its
// body stretches and gathers, its eye stalks bob and the grass and pebbles slide slowly past
// behind it over a glistening trail. For "coming soon" and features on their way. Animated.

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

export const emptySnailTheme = {
  body: '#e9c79b',
  bodyShade: '#d4a873',
  shell: '#c9824a',
  shellLine: '#9a5a2e',
  flag: '#5b9bd5',
  pole: '#7a6a5e',
  ground: '#b9dca8',
  pebbles: '#c9c3b8',
  trail: '#ffffff',
  eyes: '#3d3a3a',
};
export type EmptySnailTheme = typeof emptySnailTheme;

// Ground details that travel right to left across the frame, one setting off every 5s on a 40s
// trip, fading in and out at the ends: alternately a grass tuft and a pebble.
const DETAILS = Array.from({ length: 8 }, (_, i) => ({ pebble: i % 2 === 1, size: [1, 0.8, 1.2, 1, 0.9, 1.3, 1.1, 0.8][i], delay: -i * 5 }));

/** A snail with a flag crawling along at its own pace: for "coming soon". */
export function EmptySnail(props: EmptyStateProps<EmptySnailTheme>) {
  return (
    <EmptyStateFrame
      {...props}
      defaultTheme={emptySnailTheme}
      art={
        <>
          <rect x="10" y="149" width="220" height="5" rx="2.5" fill="var(--ground)" />

          {/* Grass and pebbles passing slowly behind, so the snail seems to make progress */}
          {DETAILS.map((d, i) => (
            <g key={i} className={styles.pass} style={{ animationDelay: `${d.delay}s` }}>
              <g transform={`translate(226 150) scale(${d.size})`}>
                {d.pebble ? (
                  <ellipse cx="0" cy="-1" rx="4" ry="2.8" fill="var(--pebbles)" />
                ) : (
                  <path d="M0 0 q-2 -8 -6 -11 M3 0 q0 -9 3 -12 M6 0 q2 -6 6 -8" fill="none" stroke="var(--ground)" strokeWidth="2.4" strokeLinecap="round" />
                )}
              </g>
            </g>
          ))}

          {/* The glistening trail it leaves behind */}
          <path className={styles.trail} d="M30 150 H104" stroke="var(--trail)" strokeWidth="2" strokeLinecap="round" strokeDasharray="1 7" />

          {/* Snail: the foot stretches and gathers; the shell rides on it with a small lag */}
          <g transform="translate(120 150)">
            <g className={styles.stretch}>
              <path d="M-26 0 C-28 -6 -18 -10 0 -10 H22 C30 -10 34 -14 36 -22 C38 -30 50 -30 50 -20 C50 -10 44 0 36 0 Z" fill="var(--body)" />
              <path d="M-26 0 H36 C40 0 44 -1 46 -3 C40 -2 30 -2 -20 -2 Z" fill="var(--body-shade)" />
            </g>
            {/* Eye stalks */}
            <g className={styles.head}>
              <g stroke="var(--body)" strokeWidth="3" strokeLinecap="round" fill="none">
                <path className={styles.stalk} d="M42 -26 Q42 -36 46 -42" />
                <path className={`${styles.stalk} ${styles.stalk2}`} d="M46 -25 Q50 -34 55 -38" />
              </g>
              <circle cx="46" cy="-43" r="3" fill="var(--body)" />
              <circle cx="55" cy="-39" r="3" fill="var(--body)" />
              <circle cx="47" cy="-43.5" r="1.4" fill="var(--eyes)" />
              <circle cx="56" cy="-39.5" r="1.4" fill="var(--eyes)" />
              <path d="M45 -14 Q48 -12 50 -15" fill="none" stroke="var(--eyes)" strokeWidth="1.3" strokeLinecap="round" />
            </g>
            <g className={styles.ride}>
              {/* Shell: a spiral drawn as nested arcs */}
              <circle cx="4" cy="-32" r="26" fill="var(--shell)" />
              <path
                d="M4 -32 m0 -4 a4 4 0 1 1 -4 4 a8 8 0 0 1 8 -8 a12 12 0 0 1 12 12 a16 16 0 0 1 -16 16 a20 20 0 0 1 -20 -20 a22 22 0 0 1 22 -22"
                fill="none"
                stroke="var(--shell-line)"
                strokeWidth="2.6"
                strokeLinecap="round"
              />
              <path d="M-14 -46 Q-6 -55 6 -56" fill="none" stroke="#ffffff" strokeOpacity="0.35" strokeWidth="3" strokeLinecap="round" />
              {/* Flag on a little pole stuck in the shell */}
              <path d="M10 -56 V-90" stroke="var(--pole)" strokeWidth="2.2" strokeLinecap="round" />
              <g transform="translate(11 -89)">
                <path className={styles.flag} d="M0 0 C8 -2 14 3 22 1 L22 15 C14 17 8 12 0 14 Z" fill="var(--flag)" />
              </g>
            </g>
          </g>
        </>
      }
    />
  );
}
components/empty-states/empty-snail.module.css
/* One 2.4s stride: the foot stretches forward, then gathers up, and the shell and head ride along
   a beat behind. Tufts and pebbles pass slowly behind, each crossing the frame in 40s. */
.stretch {
  transform-box: fill-box;
  transform-origin: 0% 100%;
  animation: stretch 2.4s ease-in-out infinite;
}
.ride { animation: ride 2.4s ease-in-out infinite; animation-delay: -0.2s; }
.head { animation: head 2.4s ease-in-out infinite; }
.stalk {
  transform-box: fill-box;
  transform-origin: 0% 100%;
  animation: bob 1.6s ease-in-out infinite alternate;
}
.stalk2 { animation-delay: -0.8s; }

.flag {
  transform-box: fill-box;
  transform-origin: 0% 50%;
  animation: wave 1.2s ease-in-out infinite alternate;
}

.pass {
  opacity: 0;
  animation: pass 40s linear infinite;
}
.trail { animation: shine 2.4s ease-in-out infinite alternate; }

@keyframes stretch {
  0%, 100% { transform: scaleX(1); }
  50%      { transform: scaleX(1.08); }
}
@keyframes ride {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(2.5px, -1px); }
}
@keyframes head {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(5px, 0); }
}
@keyframes bob {
  from { transform: rotate(-6deg); }
  to   { transform: rotate(6deg); }
}
@keyframes wave {
  from { transform: skewY(-6deg) scaleX(1); }
  to   { transform: skewY(5deg) scaleX(0.9); }
}
@keyframes pass {
  0%   { transform: translateX(0); opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(-212px); opacity: 0; }
}
@keyframes shine {
  from { opacity: 0.4; }
  to   { opacity: 0.9; }
}
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; }
}