← Loaders
Bee
<BeeLoader /> A bumblebee flying figure-eights over a flower.
Loading…
<BeeLoader
size={96}
/>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<BeeLoaderTheme> | — | 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 |
|---|---|---|
body | --body | #f6c445 |
stripes | --stripes | #3d3a3a |
wings | --wings | #e6f3fb |
petals | --petals | #f49ab5 |
centre | --centre | #f2c14e |
stem | --stem | #6cbf6a |
Source files
Copy into components/loaders/. The shared files are needed once for all loaders.
// <BeeLoader>: a bumblebee flying figure-eights over a flower, wings a blur, turning to face
// the way it's going. The figure-eight is two eased sways at a 1:2 ratio (a Lissajous curve).
import { LoaderFrame, type LoaderProps } from './loader';
import styles from './bee-loader.module.css';
export const beeLoaderTheme = {
body: '#f6c445',
stripes: '#3d3a3a',
wings: '#e6f3fb',
petals: '#f49ab5',
centre: '#f2c14e',
stem: '#6cbf6a',
};
export type BeeLoaderTheme = typeof beeLoaderTheme;
/** A bumblebee flying figure-eights over a flower. */
export function BeeLoader(props: LoaderProps<BeeLoaderTheme>) {
return (
<LoaderFrame
{...props}
defaultTheme={beeLoaderTheme}
art={
<>
{/* Flower: sways from the base of its stem */}
<g className={styles.flower}>
<path d="M50 94 C50 84 48 78 50 70" fill="none" stroke="var(--stem)" strokeWidth="3" strokeLinecap="round" />
<path d="M50 86 C44 84 40 80 40 76 C45 76 49 80 50 86 Z" fill="var(--stem)" />
<g fill="var(--petals)">
{[0, 72, 144, 216, 288].map(a => (
<ellipse key={a} cx="50" cy="62" rx="4.6" ry="7" transform={`rotate(${a} 50 69)`} />
))}
</g>
<circle cx="50" cy="69" r="4.5" fill="var(--centre)" />
</g>
{/* Bee: x sways once per 2.4s, y twice, a quarter-beat apart, tracing a figure eight */}
<g transform="translate(50 36)">
<g className={styles.x}>
<g className={styles.y}>
<g className={styles.face}>
<g className={styles.bob}>
<ellipse className={styles.wing} cx="-2" cy="-7" rx="5" ry="7" fill="var(--wings)" opacity="0.9" />
<ellipse className={`${styles.wing} ${styles.wingBack}`} cx="2" cy="-6" rx="4" ry="6" fill="var(--wings)" opacity="0.7" />
<path d="M-12 1 L-15 2 L-12 3 Z" fill="var(--stripes)" />
<ellipse cx="0" cy="2" rx="12" ry="8.5" fill="var(--body)" />
<path d="M-5 -6 Q-7 2 -5 10 M1 -6.5 Q-1 2 1 10.5" fill="none" stroke="var(--stripes)" strokeWidth="3" />
<circle cx="9" cy="1" r="5" fill="var(--stripes)" />
<circle cx="10.5" cy="0" r="1.3" fill="#ffffff" />
<path d="M9 -4 Q10 -9 13 -10" fill="none" stroke="var(--stripes)" strokeWidth="1.2" strokeLinecap="round" />
</g>
</g>
</g>
</g>
</g>
</>
}
/>
);
}
/* A 2.4s figure eight. ease-in-out alternate approximates a sine, so x (1.2s each way) and
y (0.6s each way, delayed a quarter of its cycle) trace a Lissajous curve at 1:2. */
.x { animation: x 1.2s ease-in-out infinite alternate; }
.y { animation: y 0.6s ease-in-out infinite alternate; animation-delay: -0.3s; }
/* Faces right on the way right, left on the way back; the turn happens at the ends of the sway. */
.face { animation: face 2.4s linear infinite; }
.bob { animation: bob 0.3s ease-in-out infinite alternate; }
.wing {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: flap 0.08s linear infinite alternate;
}
.wingBack { animation-delay: -0.04s; }
.flower {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: sway 2.4s ease-in-out infinite alternate;
}
@keyframes x {
from { transform: translateX(-26px); }
to { transform: translateX(26px); }
}
@keyframes y {
from { transform: translateY(-9px); }
to { transform: translateY(9px); }
}
@keyframes face {
0%, 48% { transform: scaleX(1); }
52%, 98% { transform: scaleX(-1); }
100% { transform: scaleX(1); }
}
@keyframes bob {
from { transform: rotate(-4deg); }
to { transform: rotate(4deg); }
}
@keyframes flap {
from { transform: scaleY(1); }
to { transform: scaleY(0.35); }
}
@keyframes sway {
from { transform: rotate(-4deg); }
to { transform: rotate(4deg); }
}
// 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; }
}