← Empty states
Fishing
<EmptyFishing /> A bobber in a quiet pond, fish swimming past. For "no results".
Nothing here yet
Your message goes here, with an action if you need one.
<EmptyFishing>
{children}
</EmptyFishing>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptyFishingTheme> | — | Colour overrides. Keys listed under theme tokens. |
size | number | 240 | Illustration width in pixels. Scales down to fit narrow containers. |
paused | boolean | false | Freezes the animation. |
className | string | — | Applied to the root element (a centred column). |
style | CSSProperties | — | Merged into the root element style, after theme variables. |
children | ReactNode | — | Your message and actions, shown under the illustration. |
| Theme key | CSS variable | Default |
|---|---|---|
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.
// <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>
</>
}
/>
);
}
/* 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); }
}
// 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>
);
}
.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; }
}