← Scenes
Lighthouse
<LighthouseScene /> Rotating beam over a night sea. Ship on the horizon.
<LighthouseScene />Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 80 | Seconds for the steamer's crossing. |
theme | Partial<LighthouseTheme> | — | Colour overrides. Keys listed under theme tokens. |
paused | boolean | false | Freezes all motion. Also paused automatically while off-screen. |
className | string | — | Applied to the root element. |
style | CSSProperties | — | Merged into the root element style, after theme variables. |
children | ReactNode | — | Rendered above the art in an absolutely positioned layer. |
| Theme key | CSS variable | Default |
|---|---|---|
skyTop | --sky-top | #0a1430 |
skyBottom | --sky-bottom | #2b3f6e |
moon | --moon | #f3edd8 |
beam | --beam | #fff1c4 |
land | --land | #0d162d |
water1 | --water-1 | #26395f |
water2 | --water-2 | #1b2b50 |
water3 | --water-3 | #132142 |
water4 | --water-4 | #0c1733 |
Source files
Copy into components/scenes/. The shared files are needed once for all scenes.
import { W, seeded } from './geometry';
import { SceneFrame, WaveLayer, sceneStyles as base, useSvgId, type SceneProps, type WaveLayerProps } from './scene';
import styles from './lighthouse-scene.module.css';
export const lighthouseTheme = {
skyTop: '#0a1430',
skyBottom: '#2b3f6e',
moon: '#f3edd8',
beam: '#fff1c4',
land: '#0d162d',
water1: '#26395f',
water2: '#1b2b50',
water3: '#132142',
water4: '#0c1733',
};
export type LighthouseTheme = typeof lighthouseTheme;
const HORIZON = 432;
const LAMP = { x: 318, y: 283 };
// Starfield, kept above the horizon and clear of the moon.
const rand = seeded(7);
const STARS = Array.from({ length: 48 }, () => {
const x = rand() * W, y = 20 + rand() * 360;
return { x, y, r: 0.5 + rand() * 1.2, dur: 2 + rand() * 3, delay: rand() * 5 };
}).filter(s => Math.hypot(s.x - 96, s.y - 150) > 40);
// Tower tapers from half-width 15 at the base (y 404) to 10 at the gallery (y 300).
const halfWidth = (y: number) => 10 + (5 * (y - 300)) / 104;
const towerBand = (y1: number, y2: number) =>
`M${LAMP.x - halfWidth(y1)} ${y1} L${LAMP.x + halfWidth(y1)} ${y1} ` +
`L${LAMP.x + halfWidth(y2)} ${y2} L${LAMP.x - halfWidth(y2)} ${y2} Z`;
const FRONT_WAVES: WaveLayerProps[] = [
{ y: 470, components: [[4, 100]], fill: 'var(--water-2)', drift: 13, swell: 4.4, delay: 1, reverse: true },
{ y: 540, components: [[7, W / 3]], fill: 'var(--water-3)', drift: 10, swell: 3.8, delay: 2 },
{ y: 620, components: [[10, 200]], fill: 'var(--water-4)', drift: 7, swell: 4.6, delay: 3, reverse: true },
];
/** A lighthouse sweeping its beam over a moonlit sea while a distant steamer crosses the horizon. */
export function LighthouseScene(props: SceneProps<LighthouseTheme>) {
const sky = useSvgId('sky');
const moonGlow = useSvgId('moonglow');
const beam = useSvgId('beam');
const lampGlow = useSvgId('lampglow');
return (
<SceneFrame
{...props}
defaultTheme={lighthouseTheme}
defaultDuration={80}
art={
<>
<defs>
<linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--sky-top)" />
<stop offset="0.62" stopColor="var(--sky-bottom)" />
</linearGradient>
<radialGradient id={moonGlow}>
<stop offset="0" stopColor="var(--moon)" stopOpacity="0.45" />
<stop offset="1" stopColor="var(--moon)" stopOpacity="0" />
</radialGradient>
<linearGradient id={beam} x1="1" y1="0" x2="0" y2="0">
<stop offset="0" stopColor="var(--beam)" stopOpacity="0.65" />
<stop offset="1" stopColor="var(--beam)" stopOpacity="0" />
</linearGradient>
<radialGradient id={lampGlow}>
<stop offset="0" stopColor="var(--beam)" stopOpacity="1" />
<stop offset="1" stopColor="var(--beam)" stopOpacity="0" />
</radialGradient>
</defs>
<rect width={W} height="700" fill={`url(#${sky})`} />
<g fill="#fff">
{STARS.map((s, i) => (
<circle
key={i}
className={styles.star}
cx={s.x.toFixed(1)}
cy={s.y.toFixed(1)}
r={s.r.toFixed(2)}
style={{ animationDuration: `${s.dur.toFixed(2)}s`, animationDelay: `${(-s.delay).toFixed(2)}s` }}
/>
))}
</g>
<circle cx="96" cy="150" r="90" fill={`url(#${moonGlow})`} />
<circle cx="96" cy="150" r="24" fill="var(--moon)" />
{/* Distant steamer: origin at its waterline */}
<g className={styles.ship} fill="#0a1226">
<path d="M-28 -6 L30 -6 L26 2 L-24 2 Z" />
<rect x="-14" y="-14" width="22" height="8" />
<rect x="0" y="-21" width="5" height="7" />
<g fill="#ffd98a">
<rect x="-11" y="-12" width="2" height="2" />
<rect x="-6" y="-12" width="2" height="2" />
<rect x="-1" y="-12" width="2" height="2" />
<rect x="16" y="-4" width="2" height="2" />
</g>
</g>
<WaveLayer y={HORIZON} components={[[2, 80]]} fill="var(--water-1)" drift={18} swell={5.2} />
<g fill="var(--moon)">
<rect className={base.glint} x="78" y="442" width="36" height="2" rx="1" style={{ animationDuration: '1.9s' }} />
<rect className={base.glint} x="86" y="452" width="22" height="2" rx="1" style={{ animationDuration: '2.6s', animationDelay: '-1.2s' }} />
<rect className={base.glint} x="72" y="461" width="16" height="1.6" rx="0.8" style={{ animationDuration: '1.5s', animationDelay: '-0.4s' }} />
</g>
{/* Headland, keeper's cottage and lighthouse */}
<path fill="var(--land)" d="M215 700 L222 470 L238 440 L262 418 L300 404 L400 398 L400 700 Z" />
<rect x="338" y="388" width="28" height="14" fill="#18223f" />
<path d="M335 389 L352 378 L369 389 Z" fill="#18223f" />
<rect x="344" y="393" width="5" height="4" fill="#ffd98a" />
<path d={towerBand(404, 300)} fill="#e6e0d4" />
<path d={towerBand(386, 366)} fill="#a8322c" />
<path d={towerBand(345, 325)} fill="#a8322c" />
<rect x="304" y="294" width="28" height="6" fill="#1a2238" />
<rect x="309" y="272" width="18" height="22" fill="#ffe7a3" />
<path d="M305 272 L318 258 L331 272 Z" fill="#7d2621" />
<g transform={`translate(${LAMP.x} ${LAMP.y})`}>
<g className={styles.beam}>
<path d="M0 -4 L-430 -50 L-430 50 L0 4 Z" fill={`url(#${beam})`} />
</g>
<circle className={styles.lamp} r="22" fill={`url(#${lampGlow})`} />
</g>
{FRONT_WAVES.map((w, i) => <WaveLayer key={i} {...w} />)}
</>
}
/>
);
}
.star { animation: twinkle ease-in-out infinite alternate; }
@keyframes twinkle { from { opacity: 0.15; } to { opacity: 1; } }
.ship {
transform: translate(140px, 432px);
animation: steam var(--duration) linear infinite;
animation-delay: calc(var(--duration) * -0.25);
}
@keyframes steam {
from { transform: translate(-60px, 432px); }
to { transform: translate(460px, 432px); }
}
/* Squashing the beam through zero reads as a rotating light; the lamp flashes as it faces us. */
.beam { animation: sweep 10s ease-in-out infinite; }
.lamp { animation: flash 5s ease-in-out infinite; }
@keyframes sweep { 0%, 100% { transform: scaleX(1); } 50% { transform: scaleX(-1); } }
@keyframes flash { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
// Shared geometry for the scenes. Every scene draws into a portrait 400×700 viewBox.
export const W = 400;
export const H = 700;
/** One sine term of a ridge: [amplitude, wavelength, phase?]. */
export type Ridge = ReadonlyArray<readonly [amp: number, len: number, phase?: number]>;
/** Height of a ridge made of summed sines at x. */
export function ridgeY(x: number, y: number, components: Ridge): number {
let h = y;
for (const [amp, len, phase = 0] of components) h += amp * Math.sin((x / len) * Math.PI * 2 + phase);
return h;
}
/**
* A closed band 2×W wide whose edge follows ridgeY, filled toward `toward` (H = down, 0 = up).
* Sliding it by W loops seamlessly as long as every wavelength divides W evenly.
*/
export function bandPath(y: number, components: Ridge, toward = H): string {
let d = `M0 ${toward}`;
for (let x = 0; x <= W * 2; x += 8) d += ` L${x} ${ridgeY(x, y, components).toFixed(1)}`;
return `${d} L${W * 2} ${toward} Z`;
}
/** Deterministic PRNG so scattered details (stars, trees, bubbles) render identically on server and client. */
export function seeded(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
import { useId, type CSSProperties, type ReactNode } from 'react';
import { H, bandPath, type Ridge } from './geometry';
import styles from './scene.module.css';
export { SceneFrame } from './scene-frame';
export { styles as sceneStyles };
/** Props every scene accepts. `T` is the scene's theme (colour tokens). */
export type SceneProps<T> = {
/** Seconds for the scene's main crossing. */
duration?: number;
/** Override any of the scene's colours. */
theme?: Partial<T>;
/** Freeze the animation. Scenes also pause on their own while off-screen. */
paused?: boolean;
className?: string;
style?: CSSProperties;
/** Content rendered on top of the scene (headings, buttons…). */
children?: ReactNode;
};
/** A per-instance prefix for gradient ids, so several copies of a scene can share a page. */
export function useSvgId(name: string): string {
return `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}${name}`;
}
export type WaveLayerProps = {
y: number;
components: Ridge;
fill: string;
/** Seconds per sideways loop; 0 keeps the band still. */
drift?: number;
/** Seconds per up-and-down swell; 0 disables it. */
swell?: number;
reverse?: boolean;
delay?: number;
/** Fill toward the bottom (H) or the top (0) of the scene. */
toward?: number;
opacity?: number;
};
/** A band of water, hills or sand that can drift sideways and swell. */
export function WaveLayer({ y, components, fill, drift = 0, swell = 0, reverse, delay = 0, toward = H, opacity }: WaveLayerProps) {
const path = (
<path
className={drift ? `${styles.drift} ${reverse ? styles.reverse : ''}` : undefined}
style={drift ? { animationDuration: `${drift}s` } : undefined}
fill={fill}
opacity={opacity}
d={bandPath(y, components, toward)}
/>
);
if (!swell) return path;
return (
<g className={styles.swell} style={{ animationDuration: `${swell}s`, animationDelay: `${-delay}s` }}>
{path}
</g>
);
}
'use client';
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { H, W } from './geometry';
import styles from './scene.module.css';
// skyTop → --sky-top, water1 → --water-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;
export type SceneFrameProps = {
defaultTheme: Record<string, string>;
defaultDuration: number;
art: ReactNode;
duration?: number;
theme?: Record<string, string | undefined>;
paused?: boolean;
className?: string;
style?: CSSProperties;
children?: ReactNode;
};
/**
* The frame shared by every scene: sizing, theming, overlay and off-screen pausing.
* It's the only client component; the scene art itself renders on the server.
*/
export function SceneFrame({
defaultTheme,
defaultDuration,
art,
duration = defaultDuration,
theme,
paused,
className,
style,
children,
}: SceneFrameProps) {
const ref = useRef<HTMLDivElement>(null);
const [offscreen, setOffscreen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting));
observer.observe(el);
return () => observer.disconnect();
}, []);
const vars: Record<string, string> = { '--duration': `${duration}s` };
for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
if (value) vars[toVar(key)] = value;
}
const classes = [styles.root, (paused || offscreen) && styles.paused, className].filter(Boolean).join(' ');
return (
<div ref={ref} className={classes} style={{ ...vars, ...style } as CSSProperties}>
<svg
className={styles.art}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
focusable="false"
>
{art}
</svg>
{children && <div className={styles.overlay}>{children}</div>}
</div>
);
}
.root {
position: relative;
display: block;
width: 100%;
aspect-ratio: 4 / 7;
overflow: hidden;
contain: content;
}
.art {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.overlay {
position: absolute;
inset: 0;
}
/* Shared motion primitives used by every scene */
.drift { animation: drift linear infinite; }
.reverse { animation-name: driftReverse; }
.swell { animation: swell ease-in-out infinite alternate; }
.across { animation: across linear infinite; }
.glint { animation: glint ease-in-out infinite alternate; }
@keyframes drift { to { transform: translateX(-400px); } }
@keyframes driftReverse { from { transform: translateX(-400px); } to { transform: translateX(0); } }
@keyframes swell { from { transform: translateY(-3px); } to { transform: translateY(3px); } }
@keyframes across { from { transform: translateX(-260px); } to { transform: translateX(660px); } }
@keyframes glint { from { opacity: 0.25; } to { opacity: 0.85; } }
.paused * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
.root * { animation: none !important; }
}