← Loaders
Boat
<BoatLoader /> A sailboat riding the swell in a round porthole.
Loading…
<BoatLoader
size={96}
/>Props and theme tokens
| 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 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 files
Copy into components/loaders/. The shared files are needed once for all loaders.
// <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" />
</>
}
/>
);
}
/* 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; }
}
// 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; }
}