← Empty states
Sprout
<EmptySprout /> A seedling being watered. For "create your first…".
Nothing here yet
Your message goes here, with an action if you need one.
<EmptySprout>
{children}
</EmptySprout>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptySproutTheme> | — | 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 |
|---|---|---|
pot | --pot | #d9825b |
potShade | --pot-shade | #b8673f |
soil | --soil | #6b4a3a |
leaf | --leaf | #6cbf6a |
leafShade | --leaf-shade | #4f9a58 |
can | --can | #9cc9e3 |
canShade | --can-shade | #7fb2d2 |
water | --water | #7fb6d4 |
sun | --sun | #f2c14e |
Source files
Copy into components/empty-states/. The shared files are needed once for all empty states.
// <EmptySprout>: a seedling in a terracotta pot being watered. Drops fall from a tilted watering
// can and ripple the soil, and the sprout's two leaves sway in the sun. For "create your first…"
// and getting-started screens. Animated.
import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-sprout.module.css';
export const emptySproutTheme = {
pot: '#d9825b',
potShade: '#b8673f',
soil: '#6b4a3a',
leaf: '#6cbf6a',
leafShade: '#4f9a58',
can: '#9cc9e3',
canShade: '#7fb2d2',
water: '#7fb6d4',
sun: '#f2c14e',
};
export type EmptySproutTheme = typeof emptySproutTheme;
/** A seedling in a pot being watered: for getting-started screens. */
export function EmptySprout(props: EmptyStateProps<EmptySproutTheme>) {
return (
<EmptyStateFrame
{...props}
defaultTheme={emptySproutTheme}
art={
<>
{/* Sun with slowly turning rays */}
<g transform="translate(196 40)">
<g className={styles.rays} stroke="var(--sun)" strokeWidth="2.4" strokeLinecap="round">
{[0, 45, 90, 135, 180, 225, 270, 315].map(a => (
<path key={a} d="M0 -19 V-25" transform={`rotate(${a})`} />
))}
</g>
<circle r="13" fill="var(--sun)" />
</g>
{/* Watering can, tipped toward the pot */}
<g transform="translate(62 46) rotate(24)">
<path d="M22 -2 L50 -16 L52 -12 L26 4 Z" fill="var(--can-shade)" />
<ellipse cx="51" cy="-14" rx="3" ry="5" transform="rotate(-27 51 -14)" fill="var(--can-shade)" />
<path d="M-20 -14 H22 V18 Q22 24 16 24 H-14 Q-20 24 -20 18 Z" fill="var(--can)" />
<path d="M-16 -14 Q-2 -34 14 -14" fill="none" stroke="var(--can-shade)" strokeWidth="4" strokeLinecap="round" />
<path d="M-20 -6 H22" stroke="var(--can-shade)" strokeWidth="2" />
</g>
{/* Drops from the spout to the soil, staggered */}
<g fill="var(--water)">
{[0, 1, 2].map(i => (
// Position on the group; the path's own transform is the CSS fall.
<g key={i} transform={`translate(${113 + i * 3} 56)`}>
<path className={styles.drop} style={{ animationDelay: `${-i * 0.5}s` }} d="M0 -6 Q4.5 0 0 4 Q-4.5 0 0 -6 Z" />
</g>
))}
</g>
{/* Pot and soil */}
<ellipse cx="120" cy="166" rx="40" ry="5" fill="#000" opacity="0.08" />
<path d="M92 124 H148 L141 164 H99 Z" fill="var(--pot)" />
<path d="M120 124 H148 L141 164 H120 Z" fill="var(--pot-shade)" opacity="0.35" />
<rect x="88" y="114" width="64" height="12" rx="3" fill="var(--pot)" />
<ellipse cx="120" cy="116" rx="28" ry="4" fill="var(--soil)" />
<ellipse className={styles.ripple} cx="112" cy="116" rx="8" ry="1.6" fill="none" stroke="var(--water)" strokeWidth="1.2" />
{/* Sprout: a curved stem and two leaves that sway from the base */}
<g className={styles.sprout}>
<path d="M120 116 C120 104 118 96 121 86" fill="none" stroke="var(--leaf-shade)" strokeWidth="3" strokeLinecap="round" />
<g className={styles.leftLeaf}>
<path d="M120 92 C110 80 96 82 92 90 C100 96 112 96 120 92 Z" fill="var(--leaf)" />
<path d="M120 92 Q106 88 96 89" fill="none" stroke="var(--leaf-shade)" strokeWidth="1.2" />
</g>
<g className={styles.rightLeaf}>
<path d="M121 86 C130 72 146 72 150 80 C142 88 130 90 121 86 Z" fill="var(--leaf)" />
<path d="M121 86 Q136 80 146 79" fill="none" stroke="var(--leaf-shade)" strokeWidth="1.2" />
</g>
</g>
</>
}
/>
);
}
/* Drops fall from the spout every 1.5s, each ripple the soil as it lands; the sprout sways and
its leaves open and close a touch; the sun's rays turn slowly. */
.drop {
opacity: 0;
animation: drop 1.5s ease-in infinite;
}
.ripple {
transform-box: fill-box;
transform-origin: center;
opacity: 0;
animation: ripple 1.5s ease-out infinite;
animation-delay: -0.6s;
}
.sprout {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: sway 3.2s ease-in-out infinite alternate;
}
.leftLeaf {
transform-box: fill-box;
transform-origin: 100% 80%;
animation: leftLeaf 2.4s ease-in-out infinite alternate;
}
.rightLeaf {
transform-box: fill-box;
transform-origin: 0% 80%;
animation: rightLeaf 2.4s ease-in-out infinite alternate;
animation-delay: -1.2s;
}
.rays {
transform-box: fill-box;
transform-origin: center;
animation: turn 24s linear infinite;
}
@keyframes drop {
0% { transform: translate(0, 0); opacity: 0; }
10% { opacity: 1; }
60% { transform: translate(-4px, 54px); opacity: 1; }
64%, 100% { transform: translate(-4px, 54px); opacity: 0; }
}
@keyframes ripple {
0%, 55% { transform: scale(0.3); opacity: 0; }
62% { opacity: 0.9; }
100% { transform: scale(1.6); opacity: 0; }
}
@keyframes sway {
from { transform: rotate(-4deg); }
to { transform: rotate(4deg); }
}
@keyframes leftLeaf {
from { transform: rotate(-6deg); }
to { transform: rotate(5deg); }
}
@keyframes rightLeaf {
from { transform: rotate(6deg); }
to { transform: rotate(-5deg); }
}
@keyframes turn { to { transform: rotate(360deg); } }
// 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; }
}