← Scenes
Desert
<DesertScene /> A camel caravan crossing the dunes at sunset.
<DesertScene />Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 60 | Seconds for the caravan to cross the frame. |
theme | Partial<DesertTheme> | — | 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 | #41588c |
skyMid | --sky-mid | #f2a477 |
skyBottom | --sky-bottom | #fde1b0 |
sun | --sun | #fff1c9 |
dune1 | --dune-1 | #eab889 |
dune2 | --dune-2 | #d99a6c |
dune3 | --dune-3 | #c47a55 |
dune4 | --dune-4 | #a9603f |
silhouette | --silhouette | #3b2a35 |
Source files
Copy into components/scenes/. The shared files are needed once for all scenes.
// <DesertScene>: a camel caravan crossing a dune crest at sunset. A guide walks ahead of three
// camels, each stepping along the ridge line as it rises and dips; sand blows off the crests and
// the first stars come out. `duration` is seconds for the caravan to cross the frame (default 60).
import type { CSSProperties } from 'react';
import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry';
import { SceneFrame, useSvgId, type SceneProps } from './scene';
import styles from './desert-scene.module.css';
export const desertTheme = {
skyTop: '#41588c',
skyMid: '#f2a477',
skyBottom: '#fde1b0',
sun: '#fff1c9',
dune1: '#eab889',
dune2: '#d99a6c',
dune3: '#c47a55',
dune4: '#a9603f',
silhouette: '#3b2a35',
};
export type DesertTheme = typeof desertTheme;
const FAR: Ridge = [[10, 400, 2], [5, 200]];
const CREST_Y = 478;
const CREST: Ridge = [[6, 400, 0.5], [2, 200, 1]];
const NEAR: Ridge = [[16, 400, 3.4], [6, 100, 1]];
const FRONT: Ridge = [[12, 200, 0.6], [4, 80]];
// The crest's top edge alone, for its sunlit rim.
const CREST_LINE = Array.from({ length: W / 8 + 1 }, (_, i) => `${i ? 'L' : 'M'}${i * 8} ${ridgeY(i * 8, CREST_Y, CREST).toFixed(1)}`).join(' ');
// The caravan's keyframes run along these x positions; each walker's height at every one is the
// crest's height under it, passed in as --y0 … --y10 so it stays on the sand.
const STEPS = Array.from({ length: 11 }, (_, i) => -140 + i * 68);
const WALKERS = [
{ dx: 40, kind: 'guide' as const },
{ dx: 0, kind: 'rider' as const },
{ dx: -64, kind: 'camel' as const },
{ dx: -128, kind: 'camel' as const },
];
const walkerStyle = (dx: number) => {
const vars: Record<string, string> = { '--dx': `${dx}px` };
STEPS.forEach((x, i) => (vars[`--y${i}`] = `${ridgeY(x + dx, CREST_Y, CREST).toFixed(1)}px`));
return vars as CSSProperties;
};
const rand = seeded(19);
const STARS = Array.from({ length: 26 }, () => ({
x: rand() * W,
y: 10 + rand() * 170,
r: 0.4 + rand() * 0.9,
dur: 2 + rand() * 3,
delay: rand() * 5,
}));
// Wisps of sand lifted off crests by the wind.
const WISPS = [
{ x: 60, y: ridgeY(60, 548, NEAR) - 2, delay: 0 },
{ x: 250, y: ridgeY(250, 548, NEAR) - 2, delay: -2.2 },
{ x: 330, y: ridgeY(330, CREST_Y, CREST) - 2, delay: -4.1 },
];
// Ripples raked into the nearest dune by the wind.
const RIPPLES = Array.from({ length: 9 }, (_, i) => {
const y = 628 + i * 8;
const x = 20 + rand() * 60;
return `M${x.toFixed(1)} ${y} q60 -6 120 0 t120 0`;
});
function Legs({ xs, len }: { xs: number[]; len: number }) {
return (
<>
{xs.map((x, i) => (
<g key={x} transform={`translate(${x} ${-len})`}>
<rect className={`${styles.leg} ${i % 2 ? styles.legB : ''}`} x="-1.6" y="0" width="3.2" height={len} rx="1.6" />
</g>
))}
</>
);
}
/** A camel facing right, origin on the sand under its middle. Optionally carrying a rider. */
function Camel({ rider }: { rider: boolean }) {
return (
<g className={styles.sway}>
<Legs xs={[-16, -9, 11, 18]} len={26} />
<path d="M-24 -28 C-24 -38 -16 -42 -10 -42 C-6 -54 6 -56 10 -44 C16 -44 22 -40 22 -32 L22 -26 C10 -22 -12 -22 -24 -26 Z" />
<path d="M-24 -32 Q-30 -30 -29 -22" fill="none" stroke="var(--silhouette)" strokeWidth="2" strokeLinecap="round" />
<g className={styles.nod}>
<path d="M18 -38 C26 -40 29 -48 31 -56 L40 -57 C43 -55 43 -51 39 -50 L35 -50 C33 -42 28 -31 19 -28 Z" />
</g>
{rider && (
<g>
<path d="M-4 -52 L4 -52 L6 -40 L-6 -40 Z" />
<circle cx="0" cy="-57" r="4" />
<path className={styles.scarf} d="M-3 -58 Q-12 -56 -16 -50" fill="none" stroke="var(--silhouette)" strokeWidth="2" strokeLinecap="round" />
</g>
)}
</g>
);
}
/** A guide on foot with a staff, origin on the sand. */
function Guide() {
return (
<g className={styles.sway}>
<Legs xs={[-2, 2]} len={16} />
<path d="M-5 -16 L-4 -32 Q0 -36 4 -32 L5 -16 Z" />
<circle cx="0" cy="-37" r="4" />
<path d="M8 2 L10 -40" stroke="var(--silhouette)" strokeWidth="1.6" strokeLinecap="round" />
<path d="M4 -28 L9 -24" stroke="var(--silhouette)" strokeWidth="2.4" strokeLinecap="round" />
</g>
);
}
/** A camel caravan crossing a dune crest at sunset. */
export function DesertScene(props: SceneProps<DesertTheme>) {
const sky = useSvgId('sky');
const glow = useSvgId('glow');
return (
<SceneFrame
{...props}
defaultTheme={desertTheme}
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.48" stopColor="var(--sky-mid)" />
<stop offset="0.68" stopColor="var(--sky-bottom)" />
</linearGradient>
<radialGradient id={glow}>
<stop offset="0" stopColor="var(--sun)" stopOpacity="0.9" />
<stop offset="1" stopColor="var(--sun)" stopOpacity="0" />
</radialGradient>
</defs>
<rect width={W} height="700" fill={`url(#${sky})`} />
<g fill="#ffffff">
{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>
{/* Thin streaks of cloud lit from below */}
<g fill="var(--sun)" opacity="0.55">
<rect x="40" y="268" width="150" height="3" rx="1.5" />
<rect x="80" y="278" width="90" height="2.5" rx="1.25" />
<rect x="250" y="250" width="110" height="2.5" rx="1.25" />
</g>
<circle className={styles.glow} cx="276" cy="410" r="150" fill={`url(#${glow})`} />
<circle cx="276" cy="410" r="48" fill="var(--sun)" />
<path fill="var(--dune-1)" d={bandPath(436, FAR)} />
{/* Two palms at a far oasis */}
<g fill="var(--dune-3)" opacity="0.6">
<path d="M66 430 q2 -12 0 -24 M74 432 q-2 -10 2 -20" stroke="var(--dune-3)" strokeWidth="2" fill="none" />
<path d="M66 406 q-8 0 -12 5 q6 -2 12 -1 q-4 -6 -10 -6 q8 -2 10 2 q2 -6 8 -6 q-4 3 -6 6 q8 0 10 5 q-6 -3 -12 -5 Z" />
<path d="M76 412 q-6 0 -9 4 q5 -2 9 -1 q-3 -4 -7 -4 q6 -2 7 1 q2 -4 6 -4 q-3 2 -4 4 q6 0 7 4 q-4 -2 -9 -4 Z" />
</g>
{/* The crest the caravan walks, its shaded face and a lit edge */}
<path fill="var(--dune-2)" d={bandPath(CREST_Y, CREST)} />
<path fill="none" stroke="var(--sky-bottom)" strokeOpacity="0.6" strokeWidth="1.6" d={CREST_LINE} />
{WALKERS.map((w, i) => (
<g key={i} className={styles.walk} style={walkerStyle(w.dx)} fill="var(--silhouette)">
{w.kind === 'guide' ? <Guide /> : <Camel rider={w.kind === 'rider'} />}
</g>
))}
<path fill="var(--dune-3)" d={bandPath(548, NEAR)} />
<g fill="none" stroke="var(--sky-bottom)" strokeWidth="1.4" strokeLinecap="round">
{WISPS.map((w, i) => (
<path
key={i}
className={styles.wisp}
style={{ animationDelay: `${w.delay}s` }}
d={`M${w.x.toFixed(1)} ${w.y.toFixed(1)} q14 -4 30 -2 M${(w.x + 6).toFixed(1)} ${(w.y + 3).toFixed(1)} q12 -3 24 -1`}
/>
))}
</g>
<path fill="var(--dune-4)" d={bandPath(612, FRONT)} />
<g fill="none" stroke="#000000" strokeOpacity="0.1" strokeWidth="1.4">
{RIPPLES.map((d, i) => (
<path key={i} d={d} />
))}
</g>
</>
}
/>
);
}
/* Each walker crosses once per --duration, offset by its own --dx, at the crest's height under it
at eleven points along the way (--y0 … --y10, computed from the same ridge as the dune). */
.walk {
transform: translate(calc(200px + var(--dx)), var(--y5)); /* resting spot for reduced motion */
animation: walk var(--duration) linear infinite;
animation-delay: calc(var(--duration) * -0.45);
}
/* A slow, rolling gait: legs swing in alternate pairs, the body rocks, the head nods. */
.leg {
transform-box: fill-box;
transform-origin: 50% 0%;
animation: stride 1.1s ease-in-out infinite alternate;
}
.legB { animation-delay: -1.1s; }
.sway { animation: sway 1.1s ease-in-out infinite alternate; }
.nod {
transform-box: fill-box;
transform-origin: 0% 100%;
animation: nod 2.2s ease-in-out infinite;
}
.scarf {
transform-box: fill-box;
transform-origin: 100% 0%;
animation: scarf 0.9s ease-in-out infinite alternate;
}
.wisp {
opacity: 0;
animation: wisp 6s ease-out infinite;
}
.star { animation: twinkle ease-in-out infinite alternate; }
.glow { animation: glow 4s ease-in-out infinite alternate; }
@keyframes walk {
0% { transform: translate(calc(-140px + var(--dx)), var(--y0)); }
10% { transform: translate(calc(-72px + var(--dx)), var(--y1)); }
20% { transform: translate(calc(-4px + var(--dx)), var(--y2)); }
30% { transform: translate(calc(64px + var(--dx)), var(--y3)); }
40% { transform: translate(calc(132px + var(--dx)), var(--y4)); }
50% { transform: translate(calc(200px + var(--dx)), var(--y5)); }
60% { transform: translate(calc(268px + var(--dx)), var(--y6)); }
70% { transform: translate(calc(336px + var(--dx)), var(--y7)); }
80% { transform: translate(calc(404px + var(--dx)), var(--y8)); }
90% { transform: translate(calc(472px + var(--dx)), var(--y9)); }
100% { transform: translate(calc(540px + var(--dx)), var(--y10)); }
}
@keyframes stride {
from { transform: rotate(-14deg); }
to { transform: rotate(14deg); }
}
@keyframes sway {
from { transform: translateY(0) rotate(-1deg); }
to { transform: translateY(-1px) rotate(1deg); }
}
@keyframes nod {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(6deg); }
}
@keyframes scarf {
from { transform: rotate(-8deg); }
to { transform: rotate(10deg); }
}
@keyframes wisp {
0% { transform: translate(0, 0); opacity: 0; }
15% { opacity: 0.7; }
60% { transform: translate(26px, -6px); opacity: 0; }
100% { transform: translate(26px, -6px); opacity: 0; }
}
@keyframes twinkle { from { opacity: 0.15; } to { opacity: 0.85; } }
@keyframes glow { from { opacity: 0.75; } 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; }
}