# EmptyOffline

> Two tin cans joined by a string that has gone slack and sags to the ground. Dots leave one can and fade before they reach the other, and a question mark tilts over the far can. For offline, "can't connect" and lost-connection screens.

- Page: https://www.boatui.dev/empty-states/offline
- Source: https://github.com/wi11s/boatui/blob/main/components/empty-states/empty-offline.tsx
- Animated. Generate: ~1,035 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-offline.tsx` and `empty-offline.module.css` into `components/empty-states/`. Shared files are in https://www.boatui.dev/llms-full.txt.

## Usage

```tsx
import { EmptyOffline } from '@/components/empty-states/empty-offline';

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

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `Partial<EmptyOfflineTheme>` | — | 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 |
| --- | --- | --- |
| `can` | `--can` | `#c9d6df` |
| `canShade` | `--can-shade` | `#a7b4bd` |
| `label` | `--label` | `#9cc9e3` |
| `labelAlt` | `--label-alt` | `#f2c14e` |
| `string` | `--string` | `#9a6b45` |
| `ground` | `--ground` | `#e7eef3` |
| `dots` | `--dots` | `#7fb2d2` |

## Source

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

```tsx
// <EmptyOffline>: a tin-can telephone whose string has gone slack. Dots leave one can and fade
// out before they reach the other. For offline, "can't connect" and lost-connection screens. Animated.

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

export const emptyOfflineTheme = {
  can: '#c9d6df',
  canShade: '#a7b4bd',
  label: '#9cc9e3',
  labelAlt: '#f2c14e',
  string: '#9a6b45',
  ground: '#e7eef3',
  dots: '#7fb2d2',
};
export type EmptyOfflineTheme = typeof emptyOfflineTheme;

function Can({ x, label, className }: { x: number; label: string; className?: string }) {
  return (
    <g className={className}>
      <path d={`M${x} 104 V146 a18 5 0 0 0 36 0 V104 Z`} fill="var(--can)" />
      <path d={`M${x} 116 H${x + 36} V132 H${x} Z`} fill={label} />
      <g fill="none" stroke="var(--can-shade)" strokeWidth="1.4">
        <path d={`M${x} 110 a18 5 0 0 0 36 0`} />
        <path d={`M${x} 138 a18 5 0 0 0 36 0`} />
      </g>
      <ellipse cx={x + 18} cy="104" rx="18" ry="5" fill="var(--can-shade)" />
      <ellipse cx={x + 18} cy="104.6" rx="15" ry="3.6" fill="#ffffff" opacity="0.25" />
    </g>
  );
}

/** A tin-can telephone with a slack string: for offline screens. */
export function EmptyOffline(props: EmptyStateProps<EmptyOfflineTheme>) {
  return (
    <EmptyStateFrame
      {...props}
      defaultTheme={emptyOfflineTheme}
      art={
        <>
          <ellipse cx="120" cy="154" rx="96" ry="10" fill="var(--ground)" />

          {/* Slack string: sagging to the ground and swaying, its ends fixed to the cans */}
          <g className={styles.string}>
            <path d="M72 140 C96 166 144 166 168 140" fill="none" stroke="var(--string)" strokeWidth="2" strokeLinecap="round" />
          </g>

          <Can x={36} label="var(--label)" className={styles.speaker} />
          <Can x={168} label="var(--label-alt)" />

          {/* Dots leave the left can and fade before reaching the right one */}
          <g fill="var(--dots)">
            {[0, 1, 2].map(i => (
              <circle key={i} className={styles.dot} style={{ animationDelay: `${i * 0.35}s` }} cx="58" cy="90" r="4" />
            ))}
          </g>

          {/* A question mark hangs over the right can */}
          <g className={styles.question} fill="none" stroke="var(--can-shade)" strokeWidth="3" strokeLinecap="round">
            <path d="M180 74 q0 -8 6 -8 q6 0 6 6 q0 5 -6 7 v4" />
            <circle cx="186" cy="90" r="0.6" />
          </g>
        </>
      }
    />
  );
}
```

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

```css
/* One 3.6s loop: the left can tips forward as three dots rise from it and fade halfway across;
   a question mark tilts over the right can; the slack string sways from its two fixed ends. */
.dot {
  opacity: 0;
  animation: dot 3.6s ease-out infinite;
}

.question {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: wonder 3.6s ease-in-out infinite;
}

/* The left can tips toward the other as it "speaks". */
.speaker {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: speak 3.6s ease-in-out infinite;
}

/* Scaling about the line between the two ends keeps them pinned to the cans. */
.string {
  transform-origin: 120px 140px;
  animation: slack 3.6s ease-in-out infinite alternate;
}

@keyframes dot {
  0%   { transform: translate(0, 6px); opacity: 0; }
  12%  { opacity: 1; }
  45%  { transform: translate(44px, -18px); opacity: 0.8; }
  65%  { transform: translate(60px, -14px); opacity: 0; }
  100% { transform: translate(60px, -14px); opacity: 0; }
}
@keyframes wonder {
  0%, 40%  { transform: rotate(0deg) translateY(4px); opacity: 0; }
  55%      { transform: rotate(-8deg) translateY(0); opacity: 1; }
  75%      { transform: rotate(6deg) translateY(0); opacity: 1; }
  92%, 100% { transform: rotate(0deg) translateY(-2px); opacity: 0; }
}
@keyframes speak {
  0%, 40%, 100% { transform: rotate(0deg); }
  8%  { transform: rotate(4deg); }
  16% { transform: rotate(1deg); }
  24% { transform: rotate(4deg); }
}
@keyframes slack {
  from { transform: scaleY(0.82); }
  to   { transform: scaleY(1.08); }
}
```
