← Scenes
Aurora
<AuroraScene /> Northern lights over a cabin by a frozen lake. Night.
<AuroraScene />Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 60 | Seconds for the main curtain of light to drift once across the sky. |
theme | Partial<AuroraTheme> | — | 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 | #0b1430 |
skyBottom | --sky-bottom | #1d3a55 |
aurora1 | --aurora-1 | #5cf2b0 |
aurora2 | --aurora-2 | #3fd4d0 |
aurora3 | --aurora-3 | #b48cff |
mountain1 | --mountain-1 | #2c4766 |
mountain2 | --mountain-2 | #1c3049 |
snow | --snow | #e6eef6 |
lake | --lake | #2a4c66 |
forest | --forest | #0f1d2c |
cabin | --cabin | #5a3e35 |
window | --window | #ffcf7a |
Source files
Copy into components/scenes/. The shared files are needed once for all scenes.
// <AuroraScene>: northern lights rippling over snowy mountains and a frozen lake, a cabin with a lit
// window and smoking chimney among the pines. `duration` is seconds for the main curtain of light
// to drift once across the sky (default 60); the other curtains drift at related speeds.
import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry';
import type { CSSProperties } from 'react';
import { SceneFrame, WaveLayer, useSvgId, type SceneProps } from './scene';
import styles from './aurora-scene.module.css';
export const auroraTheme = {
skyTop: '#0b1430',
skyBottom: '#1d3a55',
aurora1: '#5cf2b0',
aurora2: '#3fd4d0',
aurora3: '#b48cff',
mountain1: '#2c4766',
mountain2: '#1c3049',
snow: '#e6eef6',
lake: '#2a4c66',
forest: '#0f1d2c',
cabin: '#5a3e35',
window: '#ffcf7a',
};
export type AuroraTheme = typeof auroraTheme;
const LAKE = 548;
const FAR_RANGE: Ridge = [[34, 400, 0.6], [20, 200, 2], [9, 100, 1], [4, 50]];
const SHORE: Ridge = [[6, 200], [4, 80, 1]];
/** Curtains of light: their lower edge, how tall they reach, colour, and drift as a multiple of `duration`. */
const CURTAINS = [
{ y: 330, reach: 170, color: 'var(--aurora-3)', ridge: [[12, 400, 1], [5, 100]] as Ridge, drift: 1.6, reverse: true, opacity: 0.55 },
{ y: 390, reach: 210, color: 'var(--aurora-1)', ridge: [[16, 400], [7, 200, 2], [3, 50]] as Ridge, drift: 1, reverse: false, opacity: 0.9 },
{ y: 420, reach: 150, color: 'var(--aurora-2)', ridge: [[10, 200, 3], [4, 80]] as Ridge, drift: 0.7, reverse: true, opacity: 0.6 },
];
const rand = seeded(47);
/**
* A curtain as vertical rays: each ray rises from the wavy lower edge to a varied height.
* Heights repeat every W units, so the 2W-wide curtain drifts by W without a seam. Two paths:
* every ray, and a brighter subset that gives the light its streaks.
*/
function rays(c: (typeof CURTAINS)[number]) {
const step = 3;
const heights = Array.from({ length: W / step }, () => 0.45 + rand() * 0.55);
const bright = heights.map(() => rand() > 0.72);
let all = '';
let lit = '';
for (let i = 0; i < (W * 2) / step; i++) {
const x = i * step;
const k = i % heights.length;
const yb = ridgeY(x, c.y, c.ridge);
const swell = 0.75 + 0.25 * Math.sin((x / 200) * Math.PI * 2 + c.y);
const yt = yb - c.reach * heights[k] * swell;
const seg = ` M${x} ${yb.toFixed(1)} V${yt.toFixed(1)}`;
all += seg;
if (bright[k]) lit += seg;
}
return { all, lit };
}
const RAYS = CURTAINS.map(rays);
const STARS = Array.from({ length: 70 }, () => ({
x: rand() * W,
y: rand() * 360,
r: 0.4 + rand() * 1.1,
dur: 2 + rand() * 4,
delay: rand() * 6,
}));
// Pines along the shore, leaving a gap for the cabin.
const PINES = Array.from({ length: 22 }, (_, i) => {
const x = i * 19 + rand() * 10 - 6;
return { x, y: ridgeY(x, 618, SHORE) + 4, h: 28 + rand() * 30 };
}).filter(p => p.x < 250 || p.x > 330);
/** Northern lights over snowy mountains and a frozen lake, with a cabin among the pines. */
export function AuroraScene(props: SceneProps<AuroraTheme>) {
const sky = useSvgId('sky');
const lake = useSvgId('lake');
const glow = useSvgId('glow');
const curtain = useSvgId('curtain');
const snowcap = useSvgId('snowcap');
const curtains = CURTAINS.map((c, i) => (
<g key={i} className={styles.pulse} style={{ animationDelay: `${-i * 2.3}s` }}>
<g className={styles.lean} style={{ animationDelay: `${-i * 3.1}s` }}>
<g
className={`${styles.drift} ${c.reverse ? styles.reverse : ''}`}
style={{ '--k': c.drift } as CSSProperties}
fill="none"
stroke={`url(#${curtain}${i})`}
opacity={c.opacity}
>
<path d={RAYS[i].all} strokeWidth="3.2" opacity="0.7" />
<path d={RAYS[i].lit} strokeWidth="2" />
</g>
</g>
</g>
));
return (
<SceneFrame
{...props}
defaultTheme={auroraTheme}
defaultDuration={60}
art={
<>
<defs>
<linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--sky-top)" />
<stop offset="0.75" stopColor="var(--sky-bottom)" />
</linearGradient>
{CURTAINS.map((c, i) => (
// Brightest along the lower edge, fading to nothing as it rises.
<linearGradient key={i} id={`${curtain}${i}`} gradientUnits="userSpaceOnUse" x1="0" y1={c.y + 20} x2="0" y2={c.y - c.reach}>
<stop offset="0" stopColor={c.color} stopOpacity="0" />
<stop offset="0.08" stopColor={c.color} stopOpacity="0.95" />
<stop offset="0.3" stopColor={c.color} stopOpacity="0.45" />
<stop offset="1" stopColor={c.color} stopOpacity="0" />
</linearGradient>
))}
{/* The lights' glow on the ice, strongest at the far shore */}
<linearGradient id={lake} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--aurora-1)" stopOpacity="0.35" />
<stop offset="1" stopColor="var(--aurora-2)" stopOpacity="0" />
</linearGradient>
{/* Snow on the far peaks, fading out down their flanks */}
<linearGradient id={snowcap} gradientUnits="userSpaceOnUse" x1="0" y1="404" x2="0" y2="492">
<stop offset="0" stopColor="var(--snow)" stopOpacity="0.75" />
<stop offset="0.45" stopColor="var(--snow)" stopOpacity="0.3" />
<stop offset="1" stopColor="var(--snow)" stopOpacity="0" />
</linearGradient>
<radialGradient id={glow}>
<stop offset="0" stopColor="var(--window)" stopOpacity="0.6" />
<stop offset="1" stopColor="var(--window)" 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>
<path className={styles.meteor} d="M0 0 L60 22" stroke="#fff" strokeWidth="1.6" strokeLinecap="round" />
{/* Curtains of rays; each drifts once per (--duration × --k) seconds */}
<g>{curtains}</g>
{/* Mountains: a pale snowy range behind a darker one */}
<path fill="var(--mountain-1)" d={bandPath(470, FAR_RANGE)} />
<path fill={`url(#${snowcap})`} d={bandPath(470, FAR_RANGE)} />
<path fill="var(--mountain-2)" d={bandPath(512, [[22, 400, 2.4], [12, 200], [6, 100, 2]])} />
{/* Frozen lake, lit by the lights */}
<rect x="0" y={LAKE} width={W} height="90" fill="var(--lake)" />
<rect className={styles.shimmer} x="0" y={LAKE} width={W} height="70" fill={`url(#${lake})`} />
<g fill="none" stroke="#fff" strokeOpacity="0.18" strokeWidth="1.5" strokeLinecap="round">
<path d="M40 566 H120 M210 578 H330 M90 592 H170" />
</g>
{/* Snowy shore, pines and the cabin */}
<path fill="var(--snow)" d={bandPath(618, SHORE)} />
<g fill="var(--forest)">
{PINES.map((p, i) => (
<path
key={i}
d={`M${p.x.toFixed(1)} ${(p.y - p.h).toFixed(1)} L${(p.x + 8).toFixed(1)} ${(p.y - p.h * 0.45).toFixed(1)} L${(p.x + 4).toFixed(1)} ${(p.y - p.h * 0.45).toFixed(1)} L${(p.x + 11).toFixed(1)} ${p.y.toFixed(1)} L${(p.x - 11).toFixed(1)} ${p.y.toFixed(1)} L${(p.x - 4).toFixed(1)} ${(p.y - p.h * 0.45).toFixed(1)} L${(p.x - 8).toFixed(1)} ${(p.y - p.h * 0.45).toFixed(1)} Z`}
/>
))}
</g>
<g transform="translate(290 616)">
<circle cx="-8" cy="-16" r="34" fill={`url(#${glow})`} className={styles.glow} />
<g fill="#f4f1fa" opacity="0.85">
{[0, 1, 2].map(i => (
<circle key={i} className={styles.smoke} style={{ animationDelay: `${-i * 1.6}s` }} cx="12" cy="-48" r="3" />
))}
</g>
<rect x="8" y="-46" width="8" height="16" fill="var(--cabin)" />
<rect x="-26" y="-28" width="48" height="28" fill="var(--cabin)" />
<path d="M-32 -26 L-2 -50 L28 -26 Z" fill="var(--cabin)" />
<path d="M-34 -25 L-2 -52 L30 -25 L26 -23 L-2 -46 L-30 -23 Z" fill="var(--snow)" />
<rect x="-17" y="-20" width="12" height="10" rx="1" fill="var(--window)" />
<path d="M-11 -20 V-10 M-17 -15 H-5" stroke="var(--cabin)" strokeWidth="1.4" />
<rect x="4" y="-18" width="10" height="18" rx="1" fill="#3a2924" />
</g>
<WaveLayer y={672} components={[[5, 200], [3, 100, 2]]} fill="var(--snow)" />
<g fill="#fff" opacity="0.6">
<circle cx="60" cy="660" r="1.2" />
<circle cx="150" cy="670" r="1" />
<circle cx="340" cy="664" r="1.2" />
</g>
</>
}
/>
);
}
/* Curtains drift sideways by one 400-unit loop per (--duration × --k), breathe in brightness,
and lean gently from their lower edge, so the light seems to ripple. */
.drift { animation: drift calc(var(--duration) * var(--k, 1)) linear infinite; }
.reverse { animation-direction: reverse; }
.pulse { animation: pulse 7s ease-in-out infinite alternate; }
.lean {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: lean 11s ease-in-out infinite alternate;
}
.star { animation: twinkle ease-in-out infinite alternate; }
/* A shooting star, once every 13s, gone in a moment. */
.meteor {
opacity: 0;
animation: meteor 13s ease-out infinite;
animation-delay: -4s;
}
.smoke {
transform-box: fill-box;
transform-origin: center;
animation: smoke 4.8s ease-out infinite;
}
.shimmer { animation: pulse 5s ease-in-out infinite alternate; }
.glow { animation: glow 3.2s ease-in-out infinite alternate; }
@keyframes drift { to { transform: translateX(-400px); } }
@keyframes pulse { from { opacity: 0.55; } to { opacity: 1; } }
@keyframes lean { from { transform: skewX(-7deg); } to { transform: skewX(5deg); } }
@keyframes twinkle { from { opacity: 0.2; } to { opacity: 1; } }
@keyframes meteor {
0%, 90% { transform: translate(250px, 60px) scale(0.6); opacity: 0; }
91% { opacity: 0.9; }
96% { transform: translate(330px, 92px) scale(1); opacity: 0; }
100% { transform: translate(330px, 92px) scale(1); opacity: 0; }
}
@keyframes smoke {
0% { transform: translate(0, 0) scale(0.6); opacity: 0.7; }
100% { transform: translate(-18px, -40px) scale(2.2); opacity: 0; }
}
@keyframes glow { from { opacity: 0.7; } to { 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; }
}