← Empty states

Basket

<EmptyBasket /> An empty shopping basket with a ladybird on the handle. For an empty cart.

Animated · Source · Markdown

Nothing here yet

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

Theme
Usage
<EmptyBasket>
  {children}
</EmptyBasket>
Props and theme tokens
PropTypeDefaultDescription
themePartial<EmptyBasketTheme>—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
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.

components/empty-states/empty-basket.tsx
// <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>
        </>
      }
    />
  );
}
components/empty-states/empty-basket.module.css
/* 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); }
}
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; }
}