← Loaders
Paper plane
<PlaneLoader /> A paper plane looping the loop.
Loading…
<PlaneLoader
size={96}
/>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<PlaneLoaderTheme> | — | 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 |
|---|---|---|
paper | --paper | #ffffff |
paperShade | --paper-shade | #cfdde8 |
outline | --outline | #5b7a93 |
trail | --trail | #9cc9e3 |
cloud | --cloud | #e4eff6 |
Source files
Copy into components/loaders/. The shared files are needed once for all loaders.
// <PlaneLoader>: a paper plane looping the loop, trailing a fading dashed line, while small clouds
// drift past. The loop eases, so the plane hangs at the top and swoops through the bottom.
import { LoaderFrame, type LoaderProps } from './loader';
import styles from './plane-loader.module.css';
export const planeLoaderTheme = {
paper: '#ffffff',
paperShade: '#cfdde8',
outline: '#5b7a93',
trail: '#9cc9e3',
cloud: '#e4eff6',
};
export type PlaneLoaderTheme = typeof planeLoaderTheme;
/** Loop radius; the plane circles the centre of the box. */
const R = 28;
/** An arc of the loop from angle a0 to a1 (degrees, 0 = top, clockwise). */
const arc = (a0: number, a1: number) => {
const pt = (a: number) => {
const r = (a * Math.PI) / 180;
return `${(50 + R * Math.sin(r)).toFixed(2)} ${(50 - R * Math.cos(r)).toFixed(2)}`;
};
return `M${pt(a0)} A${R} ${R} 0 0 1 ${pt(a1)}`;
};
/** A paper plane looping the loop. */
export function PlaneLoader(props: LoaderProps<PlaneLoaderTheme>) {
return (
<LoaderFrame
{...props}
defaultTheme={planeLoaderTheme}
art={
<>
<g fill="var(--cloud)">
<g className={styles.cloud}>
<ellipse cx="20" cy="74" rx="11" ry="4.5" />
<ellipse cx="25" cy="70" rx="6.5" ry="4.5" />
</g>
<g className={`${styles.cloud} ${styles.cloudSlow}`}>
<ellipse cx="70" cy="30" rx="8" ry="3.5" />
<ellipse cx="73" cy="27.5" rx="4.5" ry="3" />
</g>
</g>
{/* Everything that loops: the trail sits behind the plane, which starts at the top heading right. */}
<g className={styles.loop}>
<g fill="none" stroke="var(--trail)" strokeWidth="2.6" strokeLinecap="round" strokeDasharray="0.1 6">
<path d={arc(-150, -95)} opacity="0.3" />
<path d={arc(-95, -50)} opacity="0.6" />
<path d={arc(-50, -12)} opacity="1" />
</g>
<g transform={`translate(50 ${50 - R})`}>
<g className={styles.plane} strokeLinejoin="round" strokeWidth="1.4" stroke="var(--outline)">
<path d="M14 0 L-10 9 L-4 1 Z" fill="var(--paper-shade)" />
<path d="M14 0 L-12 -9 L-4 1 Z" fill="var(--paper)" />
<path d="M14 0 L-4 1 L-7 5 Z" fill="var(--paper-shade)" />
</g>
</g>
</g>
</>
}
/>
);
}
/* One 1.8s loop around the centre. ease-in-out on a full turn that starts at the top makes the
plane hang at the top of the loop and swoop through the bottom. */
.loop {
transform-origin: 50px 50px;
animation: loop 1.8s ease-in-out infinite;
}
/* A little roll, faked by squashing the plane's height. */
.plane { animation: roll 0.9s ease-in-out infinite alternate; }
.cloud { animation: drift 3.6s linear infinite; }
.cloudSlow { animation-duration: 5.4s; animation-delay: -2s; }
@keyframes loop { to { transform: rotate(360deg); } }
@keyframes roll {
from { transform: scaleY(1); }
to { transform: scaleY(0.7); }
}
@keyframes drift {
0% { transform: translateX(40px); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateX(-40px); 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; }
}