← Empty states
Basket
<EmptyBasket /> An empty shopping basket with a ladybird on the handle. For an empty cart.
Nothing here yet
Your message goes here, with an action if you need one.
<EmptyBasket>
{children}
</EmptyBasket>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<EmptyBasketTheme> | — | 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 |
|---|---|---|
wicker | --wicker | #e0ae6a |
wickerShade | --wicker-shade | #b9803f |
inside | --inside | #7a5236 |
cloth | --cloth | #e4573d |
clothDots | --cloth-dots | #ffffff |
ladybird | --ladybird | #e4573d |
butterfly | --butterfly | #7aa7e0 |
spots | --spots | #2b2b33 |
Source files
Copy into components/empty-states/. The shared files are needed once for all empty states.
// <EmptyBasket>: an empty wicker shopping basket with a spotted cloth tucked over its rim. A
// ladybird walks up and over the handle and back, and a butterfly flutters round it. For an empty
// cart or bag. Animated.
import { EmptyStateFrame, type EmptyStateProps } from './empty-state';
import styles from './empty-basket.module.css';
export const emptyBasketTheme = {
wicker: '#e0ae6a',
wickerShade: '#b9803f',
inside: '#7a5236',
cloth: '#e4573d',
clothDots: '#ffffff',
ladybird: '#e4573d',
butterfly: '#7aa7e0',
spots: '#2b2b33',
};
export type EmptyBasketTheme = typeof emptyBasketTheme;
// The basket's mouth is an ellipse; the handle is a half circle on the same centre, so the
// ladybird can walk along it by rotating about that centre.
const CX = 120;
const RIM_Y = 98;
const RIM_RX = 62;
const RIM_RY = 12;
const HANDLE_R = 56;
/** y of the front edge of the rim at x. */
const rimFront = (x: number) => RIM_Y + RIM_RY * Math.sqrt(Math.max(0, 1 - ((x - CX) / RIM_RX) ** 2));
const BOTTOM = 154;
/** x of the basket's side at height y (it tapers toward the bottom). */
const side = (y: number, dir: -1 | 1) => CX + dir * (RIM_RX - ((y - RIM_Y) / (BOTTOM - RIM_Y)) * 12);
const SLATS = [72, 88, 104, 120, 136, 152, 168];
const BANDS = [114, 128, 142];
/** An empty basket with a ladybird walking over the handle: for an empty cart. */
export function EmptyBasket(props: EmptyStateProps<EmptyBasketTheme>) {
const front = `M${CX - RIM_RX} ${RIM_Y} A${RIM_RX} ${RIM_RY} 0 0 0 ${CX + RIM_RX} ${RIM_Y}`;
return (
<EmptyStateFrame
{...props}
defaultTheme={emptyBasketTheme}
art={
<>
<ellipse cx={CX} cy={BOTTOM + 3} rx="72" ry="6" fill="var(--wicker-shade)" opacity="0.25" />
{/* The empty inside, then the back of the rim */}
<ellipse cx={CX} cy={RIM_Y} rx={RIM_RX} ry={RIM_RY} fill="var(--inside)" />
<path d={`M${CX - RIM_RX} ${RIM_Y} A${RIM_RX} ${RIM_RY} 0 0 1 ${CX + RIM_RX} ${RIM_Y}`} fill="none" stroke="var(--wicker-shade)" strokeWidth="5" />
{/* Handle: wicker wrapped with a darker cord */}
<path d={`M${CX - HANDLE_R} ${RIM_Y} A${HANDLE_R} ${HANDLE_R} 0 0 1 ${CX + HANDLE_R} ${RIM_Y}`} fill="none" stroke="var(--wicker)" strokeWidth="7" />
<path
d={`M${CX - HANDLE_R} ${RIM_Y} A${HANDLE_R} ${HANDLE_R} 0 0 1 ${CX + HANDLE_R} ${RIM_Y}`}
fill="none"
stroke="var(--wicker-shade)"
strokeWidth="7"
strokeDasharray="2 6"
/>
{/* Body: tapered, with woven slats and bands */}
<path
d={`${front} L${side(BOTTOM, 1)} ${BOTTOM - 4} Q${side(BOTTOM, 1) - 2} ${BOTTOM} ${side(BOTTOM, 1) - 8} ${BOTTOM} H${side(BOTTOM, -1) + 8} Q${side(BOTTOM, -1) + 2} ${BOTTOM} ${side(BOTTOM, -1)} ${BOTTOM - 4} Z`}
fill="var(--wicker)"
/>
<g fill="none" stroke="var(--wicker-shade)" strokeLinecap="round">
{SLATS.map(x => {
const bottomX = CX + (x - CX) * ((RIM_RX - 12) / RIM_RX);
return <path key={x} d={`M${x} ${rimFront(x).toFixed(1)} L${bottomX.toFixed(1)} ${BOTTOM - 2}`} strokeWidth="2" opacity="0.55" />;
})}
{BANDS.map(y => (
<path
key={y}
d={`M${side(y, -1).toFixed(1)} ${y} Q${CX} ${y + RIM_RY * 2} ${side(y, 1).toFixed(1)} ${y}`}
strokeWidth="3"
opacity="0.7"
/>
))}
</g>
<path d={front} fill="none" stroke="var(--wicker-shade)" strokeWidth="7" strokeLinecap="round" />
{/* A spotted cloth tucked over the front rim, its corner stirring in the breeze */}
<g className={styles.cloth}>
<path d="M74 104 Q92 110 112 109 Q104 122 92 134 Q86 124 74 104 Z" fill="var(--cloth)" />
<g fill="var(--cloth-dots)">
<circle cx="84" cy="110" r="1.8" />
<circle cx="97" cy="113" r="1.8" />
<circle cx="92" cy="122" r="1.8" />
<circle cx="104" cy="110" r="1.5" />
</g>
</g>
{/* Ladybird walking up and over the handle and back */}
<g transform={`translate(${CX} ${RIM_Y})`}>
<g className={styles.walk}>
<g transform={`translate(0 ${-HANDLE_R - 5})`}>
<g className={styles.turn}>
<ellipse cx="5" cy="0" rx="2.8" ry="2.6" fill="var(--spots)" />
<ellipse cx="0" cy="0" rx="6" ry="4.6" fill="var(--ladybird)" />
<path d="M0 -4.6 V4.6" stroke="var(--spots)" strokeWidth="0.8" />
<g fill="var(--spots)">
<circle cx="-2.6" cy="-1.8" r="1.1" />
<circle cx="2" cy="-2.2" r="0.9" />
<circle cx="-1.6" cy="2" r="0.9" />
<circle cx="2.4" cy="1.8" r="1.1" />
</g>
</g>
</g>
</g>
</g>
{/* Butterfly fluttering round the basket */}
<g className={styles.flutter}>
<g transform="scale(1.6)">
<g className={styles.wings}>
<path d="M0 0 C-8 -10 -14 -4 -10 2 C-8 5 -3 4 0 0 Z" fill="var(--butterfly)" />
<path d="M0 0 C8 -10 14 -4 10 2 C8 5 3 4 0 0 Z" fill="var(--butterfly)" />
<path d="M0 0 C-6 4 -8 9 -3 9 Z M0 0 C6 4 8 9 3 9 Z" fill="var(--butterfly)" opacity="0.7" />
</g>
<path d="M0 -3 V5" stroke="var(--spots)" strokeWidth="1.4" strokeLinecap="round" />
</g>
</g>
</>
}
/>
);
}
/* One 9s loop for the ladybird: it walks up and over the handle (rotating about the handle's
centre), pauses and turns round, then walks back. */
.walk { animation: walk 9s ease-in-out infinite; }
.turn { animation: turn 9s ease-in-out infinite; }
.cloth {
transform-box: fill-box;
transform-origin: 50% 0%;
animation: stir 2.6s ease-in-out infinite alternate;
}
/* The butterfly wanders a loop round the top right of the basket, flapping as it goes. */
.flutter { animation: flutter 7s ease-in-out infinite; }
.wings {
transform-box: fill-box;
transform-origin: 50% 50%;
animation: flap 0.22s ease-in-out infinite alternate;
}
@keyframes walk {
0%, 4% { transform: rotate(-68deg); }
44%, 54% { transform: rotate(68deg); }
94%, 100% { transform: rotate(-68deg); }
}
@keyframes turn {
0%, 44% { transform: scaleX(1); }
49%, 94% { transform: scaleX(-1); }
99%, 100% { transform: scaleX(1); }
}
@keyframes stir {
from { transform: skewX(-3deg); }
to { transform: skewX(4deg); }
}
@keyframes flutter {
0%, 100% { transform: translate(196px, 58px) rotate(-8deg); }
25% { transform: translate(170px, 34px) rotate(6deg); }
50% { transform: translate(206px, 22px) rotate(-4deg); }
75% { transform: translate(216px, 48px) rotate(8deg); }
}
@keyframes flap {
from { transform: scaleX(1); }
to { transform: scaleX(0.3); }
}
// 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; }
}