# BoatLoader

> A sailboat in a round porthole, riding a wave that scrolls by exactly one wavelength per 2.4s loop. Its rise, fall and tilt are sampled from the same wave at the boat, so it climbs each crest bow-first and noses into the trough, kicking up spray. A pennant flutters, foam peels off the stern, a back swell scrolls at a slower parallax and a cloud drifts past.

- Page: https://www.boatui.dev/loaders/boat
- Source: https://github.com/wi11s/boatui/blob/main/components/loaders/boat-loader.tsx
- Animated. Generate: ~1,537 tokens (source size, single pass). Import: ~16 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 `boat-loader.tsx` and `boat-loader.module.css` into `components/loaders/`. Shared files are in https://www.boatui.dev/llms-full.txt.

## Usage

```tsx
import { BoatLoader } from '@/components/loaders/boat-loader';

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

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `Partial<BoatLoaderTheme>` | — | 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 |
| --- | --- | --- |
| `sky` | `--sky` | `#cfe6f3` |
| `ring` | `--ring` | `#9cc9e3` |
| `water` | `--water` | `#3a7fa3` |
| `waterBack` | `--water-back` | `#7fb6d4` |
| `foam` | `--foam` | `#ffffff` |
| `hull` | `--hull` | `#e4573d` |
| `sail` | `--sail` | `#fbf8f1` |
| `mast` | `--mast` | `#5a3e2b` |

## Source

`components/loaders/boat-loader.tsx`

```tsx
// <BoatLoader>: a sailboat riding the swell in a round porthole. The boat's rise, fall and tilt
// follow the height and slope of the wave under it, so it climbs each crest bow-first and
// noses into the trough with a splash. A pennant flutters and a wake trails off the stern.

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

export const boatLoaderTheme = {
  sky: '#cfe6f3',
  ring: '#9cc9e3',
  water: '#3a7fa3',
  waterBack: '#7fb6d4',
  foam: '#ffffff',
  hull: '#e4573d',
  sail: '#fbf8f1',
  mast: '#5a3e2b',
};
export type BoatLoaderTheme = typeof boatLoaderTheme;

/**
 * A filled sea whose surface is y = base - amp·sin(2πx / 50), drawn 200 units wide so it can
 * scroll left by one 50-unit wavelength per loop without a seam. The boat keyframes in the CSS
 * are sampled from the same function at x = 50.
 */
const sea = (base: number, amp: number) => {
  let d = `M-50 100`;
  for (let x = -50; x <= 150; x += 2.5) d += ` L${x} ${(base - amp * Math.sin((2 * Math.PI * x) / 50)).toFixed(2)}`;
  return `${d} L150 100 Z`;
};

/** A sailboat riding the swell in a round porthole. */
export function BoatLoader(props: LoaderProps<BoatLoaderTheme>) {
  const clip = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}porthole`;
  return (
    <LoaderFrame
      {...props}
      defaultTheme={boatLoaderTheme}
      art={
        <>
          <defs>
            <clipPath id={clip}>
              <circle cx="50" cy="50" r="42" />
            </clipPath>
          </defs>
          <g clipPath={`url(#${clip})`}>
            <rect width="100" height="100" fill="var(--sky)" />
            <g className={styles.cloud} fill="var(--foam)">
              <ellipse cx="0" cy="26" rx="9" ry="3.5" />
              <ellipse cx="4" cy="23" rx="5" ry="3.5" />
            </g>
            <path className={styles.back} d={sea(57, 2)} fill="var(--water-back)" />

            {/* Boat: origin on the waterline at mid-hull (the hull sits 4 units deep), bow to the right */}
            <g className={styles.boat}>
              <path className={styles.pennant} d="M1 -40 L10 -38 L1 -36 Z" fill="var(--hull)" />
              <rect x="-0.5" y="-40" width="2.4" height="33" rx="1.2" fill="var(--mast)" />
              <path d="M-1.5 -36 Q-11 -22 -18 -10 H-1.5 Z" fill="var(--sail)" />
              <path d="M3.5 -33 Q10 -20 16 -10 H3.5 Z" fill="var(--sail)" opacity="0.85" />
              <path d="M-21 -8 H23 Q19 2 10 4 H-14 Q-19 1 -21 -8 Z" fill="var(--hull)" />
              <rect x="-20.5" y="-8" width="43.5" height="2.5" rx="1" fill="var(--sail)" opacity="0.9" />
            </g>

            <g fill="var(--foam)">
              {[0, 1, 2].map(i => (
                <rect key={i} className={styles.wake} style={{ animationDelay: `${-i * 0.4}s` }} x="26" y="61" width="7" height="2" rx="1" />
              ))}
              <circle className={styles.spray} cx="72" cy="62" r="1.8" />
              <circle className={`${styles.spray} ${styles.spray2}`} cx="70" cy="63" r="1.3" />
            </g>
            <path className={styles.front} d={sea(64, 3.2)} fill="var(--water)" />
          </g>
          <circle cx="50" cy="50" r="44" fill="none" stroke="var(--ring)" strokeWidth="4" />
        </>
      }
    />
  );
}
```

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

```css
/* One 2.4s loop. The front sea scrolls left by exactly one 50-unit wavelength, and the boat's
   keyframes are its height and slope sampled at x = 50 (rotation softened and lagging slightly,
   as a hull would), so the boat really rides the wave it sits on. */
.front { animation: scroll 2.4s linear infinite; }
.back  { animation: scroll 3.6s linear infinite; }
.cloud { animation: cloud 9s linear infinite; }

.boat { animation: ride 2.4s linear infinite; }

.pennant {
  transform-box: fill-box;
  transform-origin: 0% 50%;
  animation: flutter 0.5s ease-in-out infinite alternate;
}

/* Foam peels off the stern and drifts back with the water. */
.wake {
  transform-box: fill-box;
  transform-origin: 100% 50%;
  animation: wake 1.2s ease-out infinite;
}

/* Spray kicks up as the bow dips into the trough (around 55% of the loop). */
.spray {
  opacity: 0;
  animation: spray 2.4s ease-out infinite;
}
.spray2 { animation-name: spray2; }

@keyframes scroll { to { transform: translateX(-50px); } }
@keyframes cloud {
  from { transform: translateX(-20px); }
  to   { transform: translateX(120px); }
}
@keyframes ride {
  0%      { transform: translate(50px, 64px) rotate(-12.4deg); }
  8.33%   { transform: translate(50px, 62.4px) rotate(-13deg); }
  16.67%  { transform: translate(50px, 61.23px) rotate(-10.3deg); }
  25%     { transform: translate(50px, 60.8px) rotate(-4.7deg); }
  33.33%  { transform: translate(50px, 61.23px) rotate(2.4deg); }
  41.67%  { transform: translate(50px, 62.4px) rotate(8.7deg); }
  50%     { transform: translate(50px, 64px) rotate(12.4deg); }
  58.33%  { transform: translate(50px, 65.6px) rotate(13deg); }
  66.67%  { transform: translate(50px, 66.77px) rotate(10.3deg); }
  75%     { transform: translate(50px, 67.2px) rotate(4.7deg); }
  83.33%  { transform: translate(50px, 66.77px) rotate(-2.4deg); }
  91.67%  { transform: translate(50px, 65.6px) rotate(-8.7deg); }
  100%    { transform: translate(50px, 64px) rotate(-12.4deg); }
}
@keyframes flutter {
  from { transform: scaleX(1) skewY(0deg); }
  to   { transform: scaleX(0.8) skewY(-8deg); }
}
@keyframes wake {
  0%   { transform: translateX(0) scaleX(0.5); opacity: 0; }
  20%  { opacity: 0.9; }
  100% { transform: translateX(-22px) scaleX(1.2); opacity: 0; }
}
@keyframes spray {
  0%, 48% { transform: translate(0, 0); opacity: 0; }
  52%     { opacity: 1; }
  70%     { transform: translate(7px, -9px); opacity: 0.9; }
  84%     { transform: translate(10px, -3px); opacity: 0; }
  100%    { transform: translate(10px, -3px); opacity: 0; }
}
@keyframes spray2 {
  0%, 50% { transform: translate(0, 0); opacity: 0; }
  54%     { opacity: 1; }
  72%     { transform: translate(3px, -11px); opacity: 0.9; }
  88%     { transform: translate(5px, -4px); opacity: 0; }
  100%    { transform: translate(5px, -4px); opacity: 0; }
}
```
