# EmptyBalloon

> A lone red balloon drifting up and away, its string trailing and waving, as clouds slide down past it and a bird flaps by. It hangs in the frame and tilts on the breeze, so it reads as rising without leaving. For "page not found" (404), missing links and lost things.

- Page: https://www.boatui.dev/empty-states/balloon
- Source: https://github.com/wi11s/boatui/blob/main/components/empty-states/empty-balloon.tsx
- Animated. Generate: ~1,021 tokens (source size, single pass). Import: ~19 tokens.

## Install

All empty states: `npx degit wi11s/boatui/components/empty-states components/empty-states`

Only this one: copy the shared files (`empty-state.tsx`, `empty-state.module.css`) and `empty-balloon.tsx` and `empty-balloon.module.css` into `components/empty-states/`. Shared files are in https://www.boatui.dev/llms-full.txt.

## Usage

```tsx
import { EmptyBalloon } from '@/components/empty-states/empty-balloon';

export function NoProjects() {
  return (
    <EmptyBalloon>
      <h2>No projects yet</h2>
      <p>Create one to get started.</p>
    </EmptyBalloon>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `Partial<EmptyBalloonTheme>` | — | 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 tokens

| Key | CSS variable | Default |
| --- | --- | --- |
| `balloon` | `--balloon` | `#e4573d` |
| `shine` | `--shine` | `#ffffff` |
| `string` | `--string` | `#8a97a0` |
| `cloud` | `--cloud` | `#e7f1f7` |
| `bird` | `--bird` | `#5b6b78` |

## Source

`components/empty-states/empty-balloon.tsx`

```tsx
// <EmptyBalloon>: a lone red balloon drifting up and away, its string trailing, as clouds slide
// past below it and a bird flaps by. For "page not found", missing links and lost things. Animated.

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

export const emptyBalloonTheme = {
  balloon: '#e4573d',
  shine: '#ffffff',
  string: '#8a97a0',
  cloud: '#e7f1f7',
  bird: '#5b6b78',
};
export type EmptyBalloonTheme = typeof emptyBalloonTheme;

/** A lone balloon drifting up and away: for "page not found". */
export function EmptyBalloon(props: EmptyStateProps<EmptyBalloonTheme>) {
  return (
    <EmptyStateFrame
      {...props}
      defaultTheme={emptyBalloonTheme}
      art={
        <>
          {/* Clouds slide down past the balloon, so it reads as rising */}
          <g fill="var(--cloud)">
            {[
              { x: 50, w: 34, delay: 0 },
              { x: 186, w: 42, delay: -3.4 },
              { x: 120, w: 26, delay: -6.8 },
            ].map((c, i) => (
              <g key={i} className={styles.cloud} style={{ animationDelay: `${c.delay}s` }}>
                <ellipse cx={c.x} cy="0" rx={c.w} ry={c.w * 0.32} />
                <ellipse cx={c.x + c.w * 0.3} cy={-c.w * 0.22} rx={c.w * 0.5} ry={c.w * 0.32} />
                <ellipse cx={c.x - c.w * 0.35} cy={-c.w * 0.12} rx={c.w * 0.38} ry={c.w * 0.26} />
              </g>
            ))}
          </g>

          {/* A bird flapping across */}
          <g className={styles.bird}>
            <path className={styles.flap} d="M-7 0 Q-3.5 -4 0 0 Q3.5 -4 7 0" fill="none" stroke="var(--bird)" strokeWidth="1.6" strokeLinecap="round" />
          </g>

          {/* Balloon and string: the whole thing drifts and tilts; the string waves on its own */}
          <g className={styles.drift}>
            <g transform="translate(120 70)">
              <g className={styles.tilt}>
                <path className={styles.string} d="M0 42 C-8 56 8 70 0 84 C-6 94 4 104 0 114" fill="none" stroke="var(--string)" strokeWidth="1.4" strokeLinecap="round" />
                <path d="M-4 40 L4 40 L2 44 L-2 44 Z" fill="var(--balloon)" />
                <path d="M0 -38 C22 -38 32 -20 32 -2 C32 20 14 36 0 40 C-14 36 -32 20 -32 -2 C-32 -20 -22 -38 0 -38 Z" fill="var(--balloon)" />
                <path d="M-18 -18 C-16 -26 -10 -30 -4 -31" fill="none" stroke="var(--shine)" strokeOpacity="0.6" strokeWidth="4" strokeLinecap="round" />
                <circle cx="-20" cy="-8" r="2.4" fill="var(--shine)" opacity="0.5" />
              </g>
            </g>
          </g>
        </>
      }
    />
  );
}
```

`components/empty-states/empty-balloon.module.css`

```css
/* The balloon hangs in the frame, drifting and tilting, while clouds slide down past it (one every
   3.4s, on a 10.2s loop) so it seems to keep rising. Its string waves from the knot. */
.drift { animation: drift 5s ease-in-out infinite alternate; }
.tilt {
  transform-box: fill-box;
  transform-origin: 50% 30%;
  animation: tilt 3.6s ease-in-out infinite alternate;
}
.string {
  transform-box: fill-box;
  transform-origin: 50% 0%;
  animation: wave 1.8s ease-in-out infinite alternate;
}

.cloud {
  opacity: 0;
  animation: cloud 10.2s linear infinite;
}

.bird { animation: bird 9s linear infinite; animation-delay: -3s; }
.flap {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: flap 0.4s ease-in-out infinite alternate;
}

@keyframes drift {
  from { transform: translate(-8px, 4px); }
  to   { transform: translate(8px, -4px); }
}
@keyframes tilt {
  from { transform: rotate(-6deg); }
  to   { transform: rotate(5deg); }
}
@keyframes wave {
  from { transform: skewX(-8deg); }
  to   { transform: skewX(8deg); }
}
@keyframes cloud {
  0%   { transform: translateY(10px); opacity: 0; }
  12%  { opacity: 1; }
  80%  { opacity: 1; }
  100% { transform: translateY(190px); opacity: 0; }
}
@keyframes bird {
  0%   { transform: translate(260px, 40px); }
  100% { transform: translate(-30px, 26px); }
}
@keyframes flap {
  from { transform: scaleY(1); }
  to   { transform: scaleY(-0.5); }
}
```
