← Empty states

Fishing

<EmptyFishing /> A bobber in a quiet pond, fish swimming past. For "no results".

Animated · Source · Markdown

Nothing here yet

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

Theme
Usage
<EmptyFishing>
  {children}
</EmptyFishing>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptyFishingTheme>—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
water--water#9cc9e3
waterDeep--water-deep#7fb2d2
shore--shore#b9dca8
reeds--reeds#6cbf6a
cattail--cattail#9a6b45
rod--rod#9a6b45
line--line#8a97a0
bobber--bobber#e4573d
lily--lily#6cbf6a
flower--flower#f7b7c8
fish--fish#5d97b8
Source files

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

components/empty-states/empty-fishing.tsx
// <EmptyFishing>: a fishing line dropped in a quiet pond. The bobber bobs and sends out rings while
// fish glide right past it. For "no results" and empty search screens. Animated.

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

export const emptyFishingTheme = {
  water: '#9cc9e3',
  waterDeep: '#7fb2d2',
  shore: '#b9dca8',
  reeds: '#6cbf6a',
  cattail: '#9a6b45',
  rod: '#9a6b45',
  line: '#8a97a0',
  bobber: '#e4573d',
  lily: '#6cbf6a',
  flower: '#f7b7c8',
  fish: '#5d97b8',
};
export type EmptyFishingTheme = typeof emptyFishingTheme;

/** A fishing line in a quiet pond, fish swimming past the bobber: for "no results". */
export function EmptyFishing(props: EmptyStateProps<EmptyFishingTheme>) {
  const pond = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}pond`;
  return (
    <EmptyStateFrame
      {...props}
      defaultTheme={emptyFishingTheme}
      art={
        <>
          <defs>
            <clipPath id={pond}>
              <ellipse cx="124" cy="134" rx="98" ry="32" />
            </clipPath>
          </defs>

          {/* Pond on a grassy bank */}
          <ellipse cx="124" cy="137" rx="110" ry="38" fill="var(--shore)" />
          <ellipse cx="124" cy="134" rx="98" ry="32" fill="var(--water)" />

          {/* Fish glide under the surface, ignoring the hook */}
          <g clipPath={`url(#${pond})`} fill="var(--fish)" opacity="0.55">
            <g className={styles.fish}>
              <g className={styles.wag}>
                <path d="M0 0 C6 -6 16 -6 22 0 C16 6 6 6 0 0 Z M0 0 L-8 -6 L-6 0 L-8 6 Z" />
              </g>
            </g>
            <g className={`${styles.fish} ${styles.fishBack}`}>
              <g className={styles.wag}>
                <path d="M0 0 C5 -4.5 12 -4.5 17 0 C12 4.5 5 4.5 0 0 Z M0 0 L-6 -4.5 L-4.5 0 L-6 4.5 Z" />
              </g>
            </g>
            <ellipse cx="124" cy="152" rx="60" ry="8" fill="var(--water-deep)" opacity="0.5" />
          </g>

          {/* Lily pad and flower */}
          <path d="M44 132 a16 6 0 1 0 14 -5 L50 132 Z" fill="var(--lily)" />
          <g fill="var(--flower)">
            <ellipse cx="54" cy="128" rx="3" ry="5" transform="rotate(-30 54 131)" />
            <ellipse cx="54" cy="128" rx="3" ry="5" transform="rotate(30 54 131)" />
            <ellipse cx="54" cy="127" rx="3" ry="5" />
          </g>

          {/* Rod from the top left, line down to the bobber */}
          <path d="M6 58 L66 18" stroke="var(--rod)" strokeWidth="4" strokeLinecap="round" />
          <circle cx="20" cy="49" r="4" fill="none" stroke="var(--rod)" strokeWidth="2" />
          <path d="M66 18 Q108 30 132 115" fill="none" stroke="var(--line)" strokeWidth="1.2" />

          {/* Rings spreading from the bobber */}
          <g fill="none" stroke="#ffffff" strokeWidth="1.6">
            {[0, 1, 2].map(i => (
              <ellipse key={i} className={styles.ring} style={{ animationDelay: `${-i * 1.1}s` }} cx="132" cy="128" rx="12" ry="3.5" />
            ))}
          </g>

          <g className={styles.bob}>
            <path d="M132 114 V120" stroke="var(--line)" strokeWidth="1.6" strokeLinecap="round" />
            <path d="M124.5 127 a7.5 7.5 0 0 1 15 0 Z" fill="#ffffff" />
            <path d="M124.5 127 a7.5 6 0 0 0 15 0 Z" fill="var(--bobber)" />
            <path d="M124.5 127 H139.5" stroke="var(--bobber)" strokeWidth="1" />
          </g>

          {/* Reeds and cattails swaying at the right */}
          <g className={styles.reeds}>
            <g fill="none" stroke="var(--reeds)" strokeWidth="2.6" strokeLinecap="round">
              <path d="M206 140 Q204 118 208 92" />
              <path d="M214 141 Q216 120 214 102" />
              <path d="M199 141 Q194 124 192 110" />
              <path d="M220 140 Q226 126 230 118" />
            </g>
            <rect x="204.5" y="90" width="6" height="16" rx="3" fill="var(--cattail)" />
            <rect x="211" y="100" width="5.5" height="13" rx="2.75" fill="var(--cattail)" />
          </g>
        </>
      }
    />
  );
}
components/empty-states/empty-fishing.module.css
/* The bobber bobs, rings spread from it, reeds sway, and fish swim right past. */
.bob {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: bob 2.2s ease-in-out infinite alternate;
}

.ring {
  transform-box: fill-box;
  transform-origin: center;
  opacity: 0;
  animation: ring 3.3s ease-out infinite;
}

.fish { animation: swim 11s linear infinite; }
.fishBack { animation-name: swimBack; animation-duration: 15s; animation-delay: -6s; }
.wag {
  transform-box: fill-box;
  transform-origin: 30% 50%;
  animation: wag 0.6s ease-in-out infinite alternate;
}

.reeds {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: sway 4s ease-in-out infinite alternate;
}

@keyframes bob {
  from { transform: translateY(-1.5px) rotate(-3deg); }
  to   { transform: translateY(1.5px) rotate(3deg); }
}
@keyframes ring {
  0%   { transform: scale(0.4); opacity: 0; }
  15%  { opacity: 0.8; }
  100% { transform: scale(2.6); opacity: 0; }
}
/* Positions are absolute: the fish path sits at the origin. */
@keyframes swim {
  from { transform: translate(10px, 140px); }
  to   { transform: translate(250px, 146px); }
}
@keyframes swimBack {
  from { transform: translate(240px, 152px) scaleX(-1); }
  to   { transform: translate(0px, 148px) scaleX(-1); }
}
@keyframes wag {
  from { transform: skewY(-6deg); }
  to   { transform: skewY(6deg); }
}
@keyframes sway {
  from { transform: rotate(-2deg); }
  to   { transform: rotate(2.5deg); }
}
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; }
}