# PlaneLoader

> A paper plane flying a vertical loop with a dashed trail that fades behind it, while two small clouds drift past. The loop eases over 1.8s, so the plane hangs at the top and swoops through the bottom, rolling slightly as it goes. Reads as a spinner with character.

- Page: https://www.boatui.dev/loaders/plane
- Source: https://github.com/wi11s/boatui/blob/main/components/loaders/plane-loader.tsx
- Animated. Generate: ~818 tokens (source size, single pass). Import: ~17 tokens.

## Install

All loaders: `npx degit wi11s/boatui/components/loaders components/loaders`

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

## Usage

```tsx
import { PlaneLoader } from '@/components/loaders/plane-loader';

export function Saving() {
  return <PlaneLoader size={48} label="Saving your changes…" />;
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `Partial<PlaneLoaderTheme>` | — | Colour overrides. Keys listed under theme tokens. |
| `size` | `number` | 64 | Width and height in pixels. |
| `label` | `string` | "Loading…" | Announced to screen readers through role="status". |
| `paused` | `boolean` | false | Freezes the animation. |
| `className` | `string` | — | Applied to the root element (an inline-flex span). |
| `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. |

## Theme tokens

| Key | CSS variable | Default |
| --- | --- | --- |
| `paper` | `--paper` | `#ffffff` |
| `paperShade` | `--paper-shade` | `#cfdde8` |
| `outline` | `--outline` | `#5b7a93` |
| `trail` | `--trail` | `#9cc9e3` |
| `cloud` | `--cloud` | `#e4eff6` |

## Source

`components/loaders/plane-loader.tsx`

```tsx
// <PlaneLoader>: a paper plane looping the loop, trailing a fading dashed line, while small clouds
// drift past. The loop eases, so the plane hangs at the top and swoops through the bottom.

import { LoaderFrame, type LoaderProps } from './loader';
import styles from './plane-loader.module.css';

export const planeLoaderTheme = {
  paper: '#ffffff',
  paperShade: '#cfdde8',
  outline: '#5b7a93',
  trail: '#9cc9e3',
  cloud: '#e4eff6',
};
export type PlaneLoaderTheme = typeof planeLoaderTheme;

/** Loop radius; the plane circles the centre of the box. */
const R = 28;

/** An arc of the loop from angle a0 to a1 (degrees, 0 = top, clockwise). */
const arc = (a0: number, a1: number) => {
  const pt = (a: number) => {
    const r = (a * Math.PI) / 180;
    return `${(50 + R * Math.sin(r)).toFixed(2)} ${(50 - R * Math.cos(r)).toFixed(2)}`;
  };
  return `M${pt(a0)} A${R} ${R} 0 0 1 ${pt(a1)}`;
};

/** A paper plane looping the loop. */
export function PlaneLoader(props: LoaderProps<PlaneLoaderTheme>) {
  return (
    <LoaderFrame
      {...props}
      defaultTheme={planeLoaderTheme}
      art={
        <>
          <g fill="var(--cloud)">
            <g className={styles.cloud}>
              <ellipse cx="20" cy="74" rx="11" ry="4.5" />
              <ellipse cx="25" cy="70" rx="6.5" ry="4.5" />
            </g>
            <g className={`${styles.cloud} ${styles.cloudSlow}`}>
              <ellipse cx="70" cy="30" rx="8" ry="3.5" />
              <ellipse cx="73" cy="27.5" rx="4.5" ry="3" />
            </g>
          </g>

          {/* Everything that loops: the trail sits behind the plane, which starts at the top heading right. */}
          <g className={styles.loop}>
            <g fill="none" stroke="var(--trail)" strokeWidth="2.6" strokeLinecap="round" strokeDasharray="0.1 6">
              <path d={arc(-150, -95)} opacity="0.3" />
              <path d={arc(-95, -50)} opacity="0.6" />
              <path d={arc(-50, -12)} opacity="1" />
            </g>
            <g transform={`translate(50 ${50 - R})`}>
              <g className={styles.plane} strokeLinejoin="round" strokeWidth="1.4" stroke="var(--outline)">
                <path d="M14 0 L-10 9 L-4 1 Z" fill="var(--paper-shade)" />
                <path d="M14 0 L-12 -9 L-4 1 Z" fill="var(--paper)" />
                <path d="M14 0 L-4 1 L-7 5 Z" fill="var(--paper-shade)" />
              </g>
            </g>
          </g>
        </>
      }
    />
  );
}
```

`components/loaders/plane-loader.module.css`

```css
/* One 1.8s loop around the centre. ease-in-out on a full turn that starts at the top makes the
   plane hang at the top of the loop and swoop through the bottom. */
.loop {
  transform-origin: 50px 50px;
  animation: loop 1.8s ease-in-out infinite;
}

/* A little roll, faked by squashing the plane's height. */
.plane { animation: roll 0.9s ease-in-out infinite alternate; }

.cloud { animation: drift 3.6s linear infinite; }
.cloudSlow { animation-duration: 5.4s; animation-delay: -2s; }

@keyframes loop { to { transform: rotate(360deg); } }
@keyframes roll {
  from { transform: scaleY(1); }
  to   { transform: scaleY(0.7); }
}
@keyframes drift {
  0%   { transform: translateX(40px); opacity: 0; }
  20%  { opacity: 1; }
  80%  { opacity: 1; }
  100% { transform: translateX(-40px); opacity: 0; }
}
```
