# KettleLoader

> A kettle on a hob over flickering blue flames. Wisps of steam drift from the spout while it simmers, then the lid rattles, the kettle shivers and a big puff whistles out before it settles again. A 2.4s loop.

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

## Usage

```tsx
import { KettleLoader } from '@/components/loaders/kettle-loader';

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

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `Partial<KettleLoaderTheme>` | — | 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 |
| --- | --- | --- |
| `kettle` | `--kettle` | `#e4573d` |
| `kettleShade` | `--kettle-shade` | `#c2442e` |
| `handle` | `--handle` | `#3d4a57` |
| `steam` | `--steam` | `#b9c4cc` |
| `flame` | `--flame` | `#5b9bd5` |
| `flameCore` | `--flame-core` | `#a8d1f5` |
| `hob` | `--hob` | `#8a97a0` |

## Source

`components/loaders/kettle-loader.tsx`

```tsx
// <KettleLoader>: a kettle coming to the boil on a hob. Steam wisps from the spout, then the lid
// rattles and it whistles out a big puff before settling again, over blue flames.

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

export const kettleLoaderTheme = {
  kettle: '#e4573d',
  kettleShade: '#c2442e',
  handle: '#3d4a57',
  steam: '#b9c4cc',
  flame: '#5b9bd5',
  flameCore: '#a8d1f5',
  hob: '#8a97a0',
};
export type KettleLoaderTheme = typeof kettleLoaderTheme;

/** A kettle coming to the boil: steam, a rattling lid and a whistle. */
export function KettleLoader(props: LoaderProps<KettleLoaderTheme>) {
  return (
    <LoaderFrame
      {...props}
      defaultTheme={kettleLoaderTheme}
      art={
        <>
          {/* Steam from the spout: small wisps, then one big puff at the whistle */}
          <g fill="var(--steam)">
            {[0, 1, 2].map(i => (
              <g key={i} transform="translate(82 42)">
                <circle className={styles.wisp} style={{ animationDelay: `${-i * 0.4}s` }} r="3.2" />
              </g>
            ))}
            <g transform="translate(84 38)">
              <g className={styles.puff}>
                <circle cx="0" cy="0" r="6" />
                <circle cx="6" cy="-5" r="5" />
                <circle cx="-3" cy="-7" r="4.5" />
              </g>
            </g>
          </g>

          {/* Flames under the kettle */}
          <g transform="translate(50 94)">
            {[-12, 0, 12].map((x, i) => (
              <g key={x} transform={`translate(${x} 0)`}>
                <g className={styles.flame} style={{ animationDelay: `${-i * 0.13}s` }}>
                  <path d="M0 -9 Q5 -2 3.5 1 Q0 3 -3.5 1 Q-5 -2 0 -9 Z" fill="var(--flame)" />
                  <path d="M0 -4 Q2.2 -0.5 1.5 1 Q0 2 -1.5 1 Q-2.2 -0.5 0 -4 Z" fill="var(--flame-core)" />
                </g>
              </g>
            ))}
          </g>
          <rect x="24" y="93" width="52" height="4" rx="2" fill="var(--hob)" />

          {/* Kettle: body, spout and handle; the whole kettle shivers as it whistles */}
          <g className={styles.kettle}>
            <path d="M68 66 Q76 58 80 44 L85 44 Q84 58 72 72 Z" fill="var(--kettle-shade)" />
            <path d="M34 48 Q34 26 50 26 Q66 26 66 48" fill="none" stroke="var(--handle)" strokeWidth="4.5" strokeLinecap="round" />
            <path d="M24 82 Q22 50 50 46 Q78 50 76 82 Z" fill="var(--kettle)" />
            <path d="M76 82 Q78 58 60 48 Q72 56 70 82 Z" fill="var(--kettle-shade)" />
            <path d="M32 64 Q34 54 42 51" fill="none" stroke="#ffffff" strokeOpacity="0.45" strokeWidth="3" strokeLinecap="round" />
            <rect x="22" y="80" width="56" height="5" rx="2.5" fill="var(--kettle-shade)" />
            <g className={styles.lid}>
              <ellipse cx="50" cy="46" rx="15" ry="4" fill="var(--kettle-shade)" />
              <circle cx="50" cy="41.5" r="3.4" fill="var(--handle)" />
            </g>
          </g>
        </>
      }
    />
  );
}
```

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

```css
/* One 2.4s loop: wisps of steam while it simmers (0–55%), then the lid rattles, the kettle
   shivers and a big puff whistles out (60–85%), then it settles. Flames flicker throughout. */
.wisp {
  opacity: 0;
  animation: wisp 1.2s ease-out infinite;
}

.puff {
  transform-box: fill-box;
  transform-origin: 0% 100%;
  opacity: 0;
  animation: puff 2.4s ease-out infinite;
}

.lid {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: rattle 2.4s linear infinite;
}

.kettle {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: shiver 2.4s linear infinite;
}

.flame {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: flicker 0.4s ease-in-out infinite alternate;
}

@keyframes wisp {
  0%   { transform: translate(0, 0) scale(0.5); opacity: 0; }
  20%  { opacity: 0.8; }
  100% { transform: translate(8px, -16px) scale(1.2); opacity: 0; }
}
@keyframes puff {
  0%, 58% { transform: translate(0, 0) scale(0.3); opacity: 0; }
  64%     { opacity: 0.95; }
  90%     { transform: translate(6px, -14px) scale(1.3); opacity: 0; }
  100%    { transform: translate(6px, -14px) scale(1.3); opacity: 0; }
}
@keyframes rattle {
  0%, 58%, 86%, 100% { transform: translateY(0) rotate(0deg); }
  62% { transform: translateY(-3px) rotate(-6deg); }
  66% { transform: translateY(0) rotate(0deg); }
  70% { transform: translateY(-3px) rotate(5deg); }
  74% { transform: translateY(0) rotate(0deg); }
  78% { transform: translateY(-2px) rotate(-4deg); }
  82% { transform: translateY(0) rotate(0deg); }
}
@keyframes shiver {
  0%, 58%, 86%, 100% { transform: translateX(0) scale(1, 1); }
  60% { transform: translateX(0) scale(1.03, 0.97); }
  64% { transform: translateX(-1px) scale(1, 1); }
  68% { transform: translateX(1px); }
  72% { transform: translateX(-1px); }
  76% { transform: translateX(1px); }
  80% { transform: translateX(-0.5px); }
}
@keyframes flicker {
  from { transform: scale(1, 1); }
  to   { transform: scale(0.85, 1.2); }
}
```
