← Loaders
Moon
<MoonLoader /> The moon running through its phases.
Loading…
<MoonLoader
size={96}
/>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<MoonLoaderTheme> | — | 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 |
|---|---|---|
moon | --moon | #f2d98c |
crater | --crater | #e2c574 |
outline | --outline | #c9d3dc |
stars | --stars | #f2c14e |
Source files
Copy into components/loaders/. The shared files are needed once for all loaders.
// <MoonLoader>: the moon running through its phases among twinkling stars. The lit part is the disc
// minus a shadow disc that slides across it, so it works on any background; a faint outline keeps
// the new moon visible.
import { useId } from 'react';
import { LoaderFrame, type LoaderProps } from './loader';
import styles from './moon-loader.module.css';
export const moonLoaderTheme = {
moon: '#f2d98c',
crater: '#e2c574',
outline: '#c9d3dc',
stars: '#f2c14e',
};
export type MoonLoaderTheme = typeof moonLoaderTheme;
const R = 26;
/** The moon running through its phases among twinkling stars. */
export function MoonLoader(props: LoaderProps<MoonLoaderTheme>) {
const id = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
return (
<LoaderFrame
{...props}
defaultTheme={moonLoaderTheme}
art={
<>
<defs>
{/* White shows the lit part: the disc, minus a black disc that slides across it */}
<mask id={`${id}phase`} maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<circle cx="50" cy="50" r={R} fill="#ffffff" />
<g className={styles.shadow}>
<circle cx="50" cy="50" r={R + 1} fill="#000000" />
</g>
</mask>
</defs>
<circle cx="50" cy="50" r={R} fill="none" stroke="var(--outline)" strokeWidth="1.5" strokeDasharray="2 3" />
<g mask={`url(#${id}phase)`} className={styles.tilt}>
<circle cx="50" cy="50" r={R} fill="var(--moon)" />
<g fill="var(--crater)">
<circle cx="42" cy="42" r="5" />
<circle cx="59" cy="57" r="6.5" />
<circle cx="56" cy="38" r="3" />
<circle cx="41" cy="61" r="3.5" />
</g>
</g>
<g fill="var(--stars)">
{[
{ x: 14, y: 22, s: 1, d: 0 },
{ x: 86, y: 18, s: 0.8, d: -0.6 },
{ x: 88, y: 78, s: 1.1, d: -1.2 },
{ x: 12, y: 80, s: 0.7, d: -1.8 },
].map((st, i) => (
<g key={i} transform={`translate(${st.x} ${st.y}) scale(${st.s})`}>
<path className={styles.star} style={{ animationDelay: `${st.d}s` }} d="M0 -6 L1.5 -1.5 L6 0 L1.5 1.5 L0 6 L-1.5 1.5 L-6 0 L-1.5 -1.5 Z" />
</g>
))}
</g>
</>
}
/>
);
}
/* One 2.4s cycle: the shadow slides from fully covering the disc (new moon) off to the right
(full), then reappears off the left and slides back over it. The jump from right to left
happens while both positions are clear of the disc, so the loop is seamless. */
.shadow { animation: phase 2.4s linear infinite; }
.tilt {
transform-box: fill-box;
transform-origin: center;
animation: tilt 4.8s ease-in-out infinite alternate;
}
.star {
transform-box: fill-box;
transform-origin: center;
animation: twinkle 2.4s ease-in-out infinite;
}
@keyframes phase {
0% { transform: translateX(0); }
45% { transform: translateX(60px); }
50% { transform: translateX(60px); }
50.01% { transform: translateX(-60px); }
55% { transform: translateX(-60px); }
100% { transform: translateX(0); }
}
@keyframes tilt {
from { transform: rotate(-8deg); }
to { transform: rotate(8deg); }
}
@keyframes twinkle {
0%, 100% { transform: scale(0.5); opacity: 0.3; }
50% { transform: scale(1); opacity: 1; }
}
// 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; }
}