← Loaders
Fishbowl
<FishbowlLoader /> A goldfish swimming laps of its bowl.
Loading…
<FishbowlLoader
size={96}
/>Props and theme tokens
| 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 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 files
Copy into components/loaders/. The shared files are needed once for all loaders.
// <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" />
</>
}
/>
);
}
/* 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); } }
// 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; }
}