# FishbowlLoader

> A goldfish swimming laps of a round bowl, turning at each side with a squeeze through zero width, its tail flicking. Bubbles rise from swaying weed, pebbles line the bottom and the water's surface ripples. A 3s loop.

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

## Usage

```tsx
import { FishbowlLoader } from '@/components/loaders/fishbowl-loader';

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

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `Partial<FishbowlLoaderTheme>` | — | 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 |
| --- | --- | --- |
| `glass` | `--glass` | `#b9d8ea` |
| `water` | `--water` | `#d8ecf6` |
| `fish` | `--fish` | `#f08a3c` |
| `fin` | `--fin` | `#f6b26b` |
| `weed` | `--weed` | `#6cbf6a` |
| `pebbles` | `--pebbles` | `#c9b79a` |
| `bubbles` | `--bubbles` | `#ffffff` |

## Source

`components/loaders/fishbowl-loader.tsx`

```tsx
// <FishbowlLoader>: a goldfish swimming laps of a round bowl, turning at each side, while bubbles
// rise, the weed sways and the water's surface ripples.

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

export const fishbowlLoaderTheme = {
  glass: '#b9d8ea',
  water: '#d8ecf6',
  fish: '#f08a3c',
  fin: '#f6b26b',
  weed: '#6cbf6a',
  pebbles: '#c9b79a',
  bubbles: '#ffffff',
};
export type FishbowlLoaderTheme = typeof fishbowlLoaderTheme;

/** A goldfish swimming laps of a round bowl. */
export function FishbowlLoader(props: LoaderProps<FishbowlLoaderTheme>) {
  const id = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
  return (
    <LoaderFrame
      {...props}
      defaultTheme={fishbowlLoaderTheme}
      art={
        <>
          <defs>
            {/* The water: the bowl's disc below a surface line */}
            <clipPath id={`${id}water`}>
              <path d="M10 40 H90 V100 H10 Z" />
            </clipPath>
            <clipPath id={`${id}bowl`}>
              <circle cx="50" cy="55" r="36" />
            </clipPath>
          </defs>

          <g clipPath={`url(#${id}bowl)`}>
            <g clipPath={`url(#${id}water)`}>
              <rect x="0" y="0" width="100" height="100" fill="var(--water)" />
            </g>
            <g className={styles.surface}>
              <path d="M6 40 q6 -2 12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0" fill="none" stroke="var(--glass)" strokeWidth="1.6" />
            </g>

            {/* Pebbles and weed on the bottom */}
            <g fill="var(--pebbles)">
              <ellipse cx="30" cy="88" rx="10" ry="5" />
              <ellipse cx="46" cy="90" rx="9" ry="5" />
              <ellipse cx="62" cy="89" rx="10" ry="5" />
              <ellipse cx="74" cy="86" rx="7" ry="4" />
            </g>
            <g transform="translate(68 86)">
              <path className={styles.weed} d="M0 0 C-5 -10 5 -18 0 -28 C-4 -34 2 -38 0 -42" fill="none" stroke="var(--weed)" strokeWidth="3.4" strokeLinecap="round" />
            </g>
            <g transform="translate(32 86)">
              <path className={`${styles.weed} ${styles.weed2}`} d="M0 0 C4 -8 -4 -14 0 -22" fill="none" stroke="var(--weed)" strokeWidth="3" strokeLinecap="round" />
            </g>

            {/* Bubbles from the weed */}
            <g fill="none" stroke="var(--bubbles)" strokeWidth="1.2">
              {[0, 1, 2].map(i => (
                <g key={i} transform={`translate(${66 + i * 3} 60)`}>
                  <circle className={styles.bubble} style={{ animationDelay: `${-i * 0.6}s` }} r={1.6 + i * 0.5} />
                </g>
              ))}
            </g>

            {/* Fish: swims across and back, turning (a squeeze through zero width) at each side */}
            <g className={styles.swim}>
              <g className={styles.turn}>
                <g className={styles.wiggle}>
                  <path className={styles.tail} d="M-9 0 L-18 -7 Q-15 0 -18 7 Z" fill="var(--fin)" />
                  <path d="M-10 0 C-6 -8 8 -8 12 0 C8 8 -6 8 -10 0 Z" fill="var(--fish)" />
                  <path d="M-2 -6 Q2 -11 6 -6 Z" fill="var(--fin)" />
                  <circle cx="7" cy="-1.5" r="1.4" fill="#2b2b2b" />
                </g>
              </g>
            </g>
          </g>

          {/* Glass: outline, rim and a highlight */}
          <circle cx="50" cy="55" r="36" fill="none" stroke="var(--glass)" strokeWidth="3" />
          <path d="M30 22 Q50 16 70 22" fill="none" stroke="var(--glass)" strokeWidth="4" strokeLinecap="round" />
          <path d="M24 48 Q24 34 34 28" fill="none" stroke="#ffffff" strokeWidth="3" strokeLinecap="round" opacity="0.8" />
        </>
      }
    />
  );
}
```

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

```css
/* One 3s lap: the fish swims right (0–42%), turns (42–50%), swims left (50–92%) and turns back.
   The turn is a squeeze through zero width, timed with the ends of the swim. */
.swim { animation: swim 3s ease-in-out infinite; }
.turn { animation: turn 3s ease-in-out infinite; }
.wiggle { animation: wiggle 0.5s ease-in-out infinite alternate; }
.tail {
  transform-box: fill-box;
  transform-origin: 100% 50%;
  animation: tail 0.25s ease-in-out infinite alternate;
}

.weed {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: weed 2.2s ease-in-out infinite alternate;
}
.weed2 { animation-delay: -1.1s; }

.bubble {
  opacity: 0;
  animation: bubble 1.8s ease-in infinite;
}

.surface { animation: lap 2s linear infinite; }

@keyframes swim {
  0%, 100% { transform: translate(30px, 60px); }
  21%      { transform: translate(50px, 56px); }
  42%, 50% { transform: translate(70px, 60px); }
  71%      { transform: translate(50px, 64px); }
  92%      { transform: translate(30px, 60px); }
}
@keyframes turn {
  0%, 42%   { transform: scaleX(1); }
  46%       { transform: scaleX(0.1); }
  50%, 92%  { transform: scaleX(-1); }
  96%       { transform: scaleX(-0.1); }
  100%      { transform: scaleX(1); }
}
@keyframes wiggle {
  from { transform: rotate(-3deg); }
  to   { transform: rotate(3deg); }
}
@keyframes tail {
  from { transform: skewY(-12deg); }
  to   { transform: skewY(12deg); }
}
@keyframes weed {
  from { transform: skewX(-8deg); }
  to   { transform: skewX(8deg); }
}
@keyframes bubble {
  0%   { transform: translate(0, 0); opacity: 0; }
  15%  { opacity: 0.9; }
  85%  { opacity: 0.6; }
  100% { transform: translate(-2px, -22px); opacity: 0; }
}
@keyframes lap { to { transform: translateX(-24px); } }
```
