← Loaders
Kettle
<KettleLoader /> A kettle coming to the boil, whistling.
Loading…
<KettleLoader
size={96}
/>Props and theme tokens
| 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 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 files
Copy into components/loaders/. The shared files are needed once for all loaders.
// <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>
</>
}
/>
);
}
/* 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); }
}
// Shared frame for every loader: theming, sizing and an accessible status label.
// Loaders are pure server components: no client JavaScript.
import type { CSSProperties, ReactNode } from 'react';
import styles from './loader.module.css';
/** Props every loader accepts. `T` is the loader's theme (colour tokens). */
export type LoaderProps<T> = {
/** Override any of the loader's colours. */
theme?: Partial<T>;
/** Width and height in pixels (default 64). */
size?: number;
/** Text announced to screen readers (default "Loading…"). */
label?: string;
/** Freeze the animation. */
paused?: boolean;
className?: string;
style?: CSSProperties;
};
// waterTop → --water-top, leaf1 → --leaf-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;
type LoaderFrameProps<T extends Record<string, string>> = LoaderProps<T> & {
defaultTheme: T;
/** SVG content drawn in a 100×100 box. */
art: ReactNode;
};
export function LoaderFrame<T extends Record<string, string>>({
defaultTheme,
art,
theme,
size = 64,
label = 'Loading…',
paused,
className,
style,
}: LoaderFrameProps<T>) {
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
if (value) vars[toVar(key)] = value;
}
const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' ');
return (
<span role="status" className={classes} style={{ ...vars, ...style } as CSSProperties}>
<svg className={styles.art} width={size} height={size} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
{art}
</svg>
<span className={styles.label}>{label}</span>
</span>
);
}
.root {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.art {
display: block;
overflow: visible;
}
/* Visible to screen readers only. */
.label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.paused * { animation-play-state: paused !important; }
/* A loader that stops moving looks broken, so reduced motion swaps the
choreography for a slow fade that still reads as "working". */
@media (prefers-reduced-motion: reduce) {
.art * { animation: none !important; }
.art { animation: breathe 2.4s ease-in-out infinite; }
}
@keyframes breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}