← Scenes
Snowfall
<SnowfallScene /> A fox trotting through falling snow. Winter.
<SnowfallScene />Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 36 | Seconds for the fox to cross the frame. |
theme | Partial<SnowfallTheme> | — | 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 | #bcd3ee |
skyBottom | --sky-bottom | #eef4fb |
sun | --sun | #fffaf0 |
hill1 | --hill-1 | #c9d9ec |
hill2 | --hill-2 | #aec4dd |
pine | --pine | #47657f |
snow | --snow | #ffffff |
snowShadow | --snow-shadow | #d9e6f4 |
fox | --fox | #e2763a |
flake | --flake | #ffffff |
Source files
Copy into components/scenes/. The shared files are needed once for all scenes.
// <SnowfallScene>: snow falling at three depths over a quiet winter valley while a fox trots
// across the meadow. Far flakes are small and slow; near flakes are six-armed and spin as they
// sway. `duration` is seconds for the fox to cross the frame (default 36).
import type { CSSProperties } from 'react';
import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry';
import { SceneFrame, useSvgId, type SceneProps } from './scene';
import styles from './snowfall-scene.module.css';
export const snowfallTheme = {
skyTop: '#bcd3ee',
skyBottom: '#eef4fb',
sun: '#fffaf0',
hill1: '#c9d9ec',
hill2: '#aec4dd',
pine: '#47657f',
snow: '#ffffff',
snowShadow: '#d9e6f4',
fox: '#e2763a',
flake: '#ffffff',
};
export type SnowfallTheme = typeof snowfallTheme;
const FIELD = 590;
const FAR: Ridge = [[16, 400, 1], [8, 200]];
const NEAR: Ridge = [[20, 400, 2.6], [8, 100, 1]];
const MEADOW: Ridge = [[8, 400, 0.4], [4, 200, 2]];
const rand = seeded(41);
/** Three depths of snow: count, size, seconds to fall the full height, sideways sway, opacity. */
const DEPTHS = [
{ count: 40, size: [1.2, 2], fall: [18, 26], sway: [6, 14], opacity: 0.6 },
{ count: 24, size: [2.4, 3.6], fall: [12, 17], sway: [12, 24], opacity: 0.85 },
{ count: 12, size: [10, 15], fall: [8, 12], sway: [18, 34], opacity: 1 },
];
const between = ([a, b]: number[]) => a + rand() * (b - a);
// Each flake rests at a scattered (x, y), so under reduced motion the snow hangs mid-air
// instead of lining up along the top edge. The fall keyframes run relative to that point.
const FLAKES = DEPTHS.flatMap((d, depth) =>
Array.from({ length: d.count }, () => {
const y = rand() * 700;
return {
depth,
x: rand() * W,
y,
size: between(d.size),
fall: between(d.fall),
delay: rand() * 30,
sway: between(d.sway),
swayTime: 2 + rand() * 3,
spin: 6 + rand() * 8,
opacity: d.opacity,
};
}),
);
// Snow-laden pines on the far slopes, and a few large ones framing the meadow.
const FAR_PINES = Array.from({ length: 26 }, () => {
const x = rand() * W;
return { x, y: ridgeY(x, 470, NEAR) + 4, h: 14 + rand() * 12 };
});
const NEAR_PINES = [
{ x: 28, y: 640, h: 120 },
{ x: 70, y: 652, h: 84 },
{ x: 352, y: 648, h: 104 },
];
const FLAKE_ARM = 'M0 0 V-9 M0 -6 L-2.5 -8.5 M0 -6 L2.5 -8.5';
function Pine({ x, y, h, snowy }: { x: number; y: number; h: number; snowy: boolean }) {
const tiers = [0, 1, 2].map(i => {
const top = y - h + i * h * 0.26;
const half = h * (0.2 + i * 0.09);
const bottom = top + h * 0.42;
return { top, half, bottom };
});
return (
<g>
<rect x={x - h * 0.03} y={y - h * 0.12} width={h * 0.06} height={h * 0.12} fill="var(--pine)" />
{tiers.map((t, i) => (
<g key={i}>
<path d={`M${x} ${t.top} L${x + t.half} ${t.bottom} H${x - t.half} Z`} fill="var(--pine)" />
{snowy && (
<path
d={`M${x} ${t.top} L${x + t.half * 0.38} ${t.top + (t.bottom - t.top) * 0.38} Q${x} ${t.top + (t.bottom - t.top) * 0.3} ${x - t.half * 0.38} ${t.top + (t.bottom - t.top) * 0.38} Z`}
fill="var(--snow)"
/>
)}
</g>
))}
</g>
);
}
/** A red fox, facing right, origin on the snow under its middle. Legs trot in diagonal pairs. */
function Fox() {
const leg = (x: number, pair: 'a' | 'b') => (
<g transform={`translate(${x} -11)`}>
<g className={`${styles.leg} ${pair === 'b' ? styles.legB : ''}`}>
<rect x="-1.6" y="0" width="3.2" height="11" rx="1.6" fill="var(--fox)" />
<rect x="-1.6" y="6" width="3.2" height="5" rx="1.6" fill="#3d2a24" />
</g>
</g>
);
return (
<g className={styles.trot}>
<g className={styles.tail}>
<path d="M-13 -18 C-24 -26 -38 -24 -44 -14 C-36 -17 -26 -13 -14 -13 Z" fill="var(--fox)" />
<path d="M-44 -14 C-41 -19 -37 -20 -34 -19 C-36 -16 -39 -15 -44 -14 Z" fill="#ffffff" />
</g>
{leg(-9, 'b')}
{leg(9, 'a')}
<ellipse cx="0" cy="-17" rx="16" ry="8" fill="var(--fox)" />
{leg(-5, 'a')}
{leg(13, 'b')}
<ellipse cx="14" cy="-18" rx="4.5" ry="5" fill="#ffffff" />
<g className={styles.head}>
<path d="M17 -29 L18.5 -38 L23 -30 Z M22 -30 L25.5 -38.5 L28 -29 Z" fill="#3d2a24" />
<path d="M14 -24 Q16 -31 23 -31 Q28 -31 30 -27 L37 -23 Q33 -19 26 -19 Q17 -19 14 -24 Z" fill="var(--fox)" />
<path d="M24 -19.5 Q31 -19.5 37 -23 Q33 -17.5 25 -17.5 Z" fill="#ffffff" />
<circle cx="37" cy="-23" r="1.6" fill="#3d2a24" />
<path d="M25 -26 q1.5 -1.2 3 0" fill="none" stroke="#3d2a24" strokeWidth="1.2" strokeLinecap="round" />
</g>
</g>
);
}
/** Snow falling over a quiet winter valley while a fox trots across the meadow. */
export function SnowfallScene(props: SceneProps<SnowfallTheme>) {
const sky = useSvgId('sky');
const glow = useSvgId('glow');
return (
<SceneFrame
{...props}
defaultTheme={snowfallTheme}
defaultDuration={36}
art={
<>
<defs>
<linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--sky-top)" />
<stop offset="0.65" 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})`} />
<circle cx="110" cy="330" r="120" fill={`url(#${glow})`} />
<circle cx="110" cy="330" r="26" fill="var(--sun)" opacity="0.8" />
{/* Distant snowy hills and forested slopes */}
<path fill="var(--hill-1)" d={bandPath(430, FAR)} />
<path fill="var(--hill-2)" d={bandPath(470, NEAR)} />
<g opacity="0.55">
{FAR_PINES.map((p, i) => (
<Pine key={i} x={p.x} y={p.y} h={p.h} snowy={false} />
))}
</g>
{/* Meadow: a shadowed drift behind the open snow the fox crosses */}
<path fill="var(--snow-shadow)" d={bandPath(FIELD - 26, MEADOW)} />
<path fill="var(--snow)" d={bandPath(FIELD, MEADOW)} />
<g className={styles.fox}>
<g transform="scale(1.25)">
<Fox />
</g>
</g>
{NEAR_PINES.map((p, i) => (
<Pine key={i} x={p.x} y={p.y} h={p.h} snowy />
))}
{/* Drifts along the bottom edge */}
<path fill="var(--snow-shadow)" d="M0 640 C60 618 110 630 160 634 S260 614 320 628 S380 630 400 624 V700 H0 Z" />
<path fill="var(--snow)" d="M0 656 C50 640 100 650 150 652 S240 636 300 646 S370 650 400 644 V700 H0 Z" />
{/* Snow, far to near, drawn last so it falls in front of everything */}
{FLAKES.map((f, i) => (
<g key={i} transform={`translate(${f.x.toFixed(1)} ${f.y.toFixed(1)})`} opacity={f.opacity}>
<g
className={styles.fall}
style={
{
'--from': `${(-f.y - 20).toFixed(1)}px`,
'--to': `${(720 - f.y).toFixed(1)}px`,
animationDuration: `${f.fall.toFixed(2)}s`,
animationDelay: `${(-f.delay).toFixed(2)}s`,
} as CSSProperties
}
>
<g className={styles.sway} style={{ '--sway': `${f.sway.toFixed(1)}px`, animationDuration: `${f.swayTime.toFixed(2)}s` } as CSSProperties}>
{f.depth === 2 ? (
<g className={styles.spin} style={{ animationDuration: `${f.spin.toFixed(2)}s` }}>
<g transform={`scale(${(f.size / 20).toFixed(3)})`} fill="none" stroke="var(--flake)" strokeWidth="1.6" strokeLinecap="round">
{[0, 60, 120, 180, 240, 300].map(a => (
<path key={a} d={FLAKE_ARM} transform={`rotate(${a})`} />
))}
</g>
</g>
) : (
<circle r={f.size.toFixed(2)} fill="var(--flake)" />
)}
</g>
</g>
</g>
))}
</>
}
/>
);
}
/* The fox trots across the meadow once per --duration. */
.fox {
transform: translate(200px, 600px); /* resting spot for reduced motion */
animation: cross var(--duration) linear infinite;
animation-delay: calc(var(--duration) * -0.35); /* start already in view */
}
/* A trot: the body bobs twice per stride while diagonal leg pairs swing in opposition. */
.trot { animation: bob 0.25s ease-in-out infinite alternate; }
.leg {
transform-box: fill-box;
transform-origin: 50% 0%;
animation: stride 0.5s ease-in-out infinite alternate;
}
.legB { animation-delay: -0.5s; }
.tail {
transform-box: fill-box;
transform-origin: 100% 40%;
animation: tail 1s ease-in-out infinite alternate;
}
.head {
transform-box: fill-box;
transform-origin: 0% 80%;
animation: look 6s ease-in-out infinite;
}
/* Snow: each flake falls from just above the top to just below the bottom, relative to its
resting point (set per flake through --from and --to), while it sways and, up close, spins. */
.fall { animation: fall linear infinite; }
.sway { animation: sway ease-in-out infinite alternate; }
.spin { animation: spin linear infinite; }
@keyframes cross {
from { transform: translate(-70px, 600px); }
to { transform: translate(470px, 600px); }
}
@keyframes bob {
from { transform: translateY(0); }
to { transform: translateY(-1.6px); }
}
@keyframes stride {
from { transform: rotate(-24deg); }
to { transform: rotate(24deg); }
}
@keyframes tail {
from { transform: rotate(-6deg); }
to { transform: rotate(5deg); }
}
@keyframes look {
0%, 70%, 100% { transform: rotate(0deg); }
78%, 88% { transform: rotate(-10deg); }
}
@keyframes fall {
from { transform: translateY(var(--from)); }
to { transform: translateY(var(--to)); }
}
@keyframes sway {
from { transform: translateX(calc(var(--sway) * -1)); }
to { transform: translateX(var(--sway)); }
}
@keyframes spin { to { transform: rotate(360deg); } }
// 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; }
}