← Loaders
Lighthouse
<LighthouseLoader /> A lighthouse beam sweeping round.
Loading…
<LighthouseLoader
size={96}
/>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<LighthouseLoaderTheme> | — | 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 |
|---|---|---|
beam | --beam | #f6c75a |
lamp | --lamp | #fff2c4 |
tower | --tower | #f4f1ea |
stripes | --stripes | #d1493c |
roof | --roof | #3d4a57 |
rock | --rock | #6b7a86 |
water | --water | #7fb6d4 |
Source files
Copy into components/loaders/. The shared files are needed once for all loaders.
// <LighthouseLoader>: a lighthouse on a rock whose beam sweeps round and round. The beam is faked
// in 2D by squashing it through zero width, and the lamp flares each time it swings past you.
import { useId } from 'react';
import { LoaderFrame, type LoaderProps } from './loader';
import styles from './lighthouse-loader.module.css';
export const lighthouseLoaderTheme = {
beam: '#f6c75a',
lamp: '#fff2c4',
tower: '#f4f1ea',
stripes: '#d1493c',
roof: '#3d4a57',
rock: '#6b7a86',
water: '#7fb6d4',
};
export type LighthouseLoaderTheme = typeof lighthouseLoaderTheme;
/** A lighthouse whose beam sweeps round, flaring as it faces you. */
export function LighthouseLoader(props: LoaderProps<LighthouseLoaderTheme>) {
const id = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
return (
<LoaderFrame
{...props}
defaultTheme={lighthouseLoaderTheme}
art={
<>
<defs>
{/* The water line fades out at both ends instead of spilling past the box. */}
<linearGradient id={`${id}fade`} gradientUnits="userSpaceOnUse" x1="10" y1="0" x2="90" y2="0">
<stop offset="0" stopColor="#fff" stopOpacity="0" />
<stop offset="0.25" stopColor="#fff" />
<stop offset="0.75" stopColor="#fff" />
<stop offset="1" stopColor="#fff" stopOpacity="0" />
</linearGradient>
<mask id={`${id}mask`} maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<rect width="100" height="100" fill={`url(#${id}fade)`} />
</mask>
</defs>
{/* Beam: hinged at the lamp; its width runs 1 → 0 → -1 like the cosine of a turning light. */}
<g transform="translate(50 30)">
<g className={styles.beam}>
<path d="M0 -3 L46 -12 L46 12 L0 3 Z" fill="var(--beam)" opacity="0.55" />
</g>
</g>
<path d="M41 36 L39 78 H61 L59 36 Z" fill="var(--tower)" />
<path d="M40.6 46 L40 56 H60 L59.4 46 Z M39.6 66 L39.2 74 H60.8 L60.4 66 Z" fill="var(--stripes)" />
<rect x="42" y="24" width="16" height="12" rx="1.5" fill="var(--roof)" />
<rect x="44.5" y="26" width="11" height="9" rx="1" fill="var(--lamp)" />
<path d="M40 24 L50 15 L60 24 Z" fill="var(--stripes)" />
<rect x="38" y="35" width="24" height="2.5" rx="1" fill="var(--roof)" />
<circle className={styles.flare} cx="50" cy="30" r="9" fill="var(--lamp)" />
<path d="M26 86 Q32 74 44 76 H58 Q70 74 76 86 Z" fill="var(--rock)" />
<g mask={`url(#${id}mask)`}>
<g className={styles.water} fill="none" stroke="var(--water)" strokeWidth="3" strokeLinecap="round">
<path d="M-4 88 q6 -4 12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0" />
</g>
</g>
</>
}
/>
);
}
/* The beam swings 1 → -1 → 1 in 2.4s (ease-in-out alternate approximates a cosine), so it
passes through zero width — facing you — every 1.2s. The lamp flares at those moments. */
.beam { animation: sweep 1.2s ease-in-out infinite alternate; }
.flare {
transform-box: fill-box;
transform-origin: center;
animation: flare 1.2s ease-in-out infinite;
}
/* The water line laps sideways by one 24-unit wavelength. */
.water { animation: lap 2.4s linear infinite; }
@keyframes sweep {
from { transform: scaleX(1); }
to { transform: scaleX(-1); }
}
@keyframes flare {
0%, 100% { transform: scale(0.4); opacity: 0.15; }
50% { transform: scale(1.3); opacity: 0.9; }
}
@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; }
}