← Scenes
Balloon
<BalloonScene /> Hot-air balloon rising over hills. Dawn.
<BalloonScene />Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 45 | Seconds for the balloon's ascent. |
theme | Partial<BalloonTheme> | — | 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 | #a9b8d9 |
skyMid | --sky-mid | #f6c6b0 |
skyBottom | --sky-bottom | #fde3c0 |
sun | --sun | #fff1d4 |
hill1 | --hill-1 | #c9a1b1 |
hill2 | --hill-2 | #a383a0 |
hill3 | --hill-3 | #6e7f7a |
hill4 | --hill-4 | #4d6656 |
envelope | --envelope | #e4573d |
stripe | --stripe | #f3c64f |
Source files
Copy into components/scenes/. The shared files are needed once for all scenes.
import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry';
import { SceneFrame, sceneStyles as base, useSvgId, type SceneProps } from './scene';
import styles from './balloon-scene.module.css';
export const balloonTheme = {
skyTop: '#a9b8d9',
skyMid: '#f6c6b0',
skyBottom: '#fde3c0',
sun: '#fff1d4',
hill1: '#c9a1b1',
hill2: '#a383a0',
hill3: '#6e7f7a',
hill4: '#4d6656',
envelope: '#e4573d',
stripe: '#f3c64f',
};
export type BalloonTheme = typeof balloonTheme;
const HILLS: { y: number; components: Ridge; fill: string }[] = [
{ y: 450, components: [[14, 400], [6, 200, 1]], fill: 'var(--hill-1)' },
{ y: 505, components: [[20, 400, 2], [8, 200]], fill: 'var(--hill-2)' },
{ y: 575, components: [[24, 400, 4], [9, 200, 1]], fill: 'var(--hill-3)' },
{ y: 645, components: [[20, 400, 1], [10, 100]], fill: 'var(--hill-4)' },
];
const HILL_PATHS = HILLS.map(h => bandPath(h.y, h.components));
// Trees scattered along the crest of the third hill.
const rand = seeded(11);
const TREES = Array.from({ length: 11 }, () => {
const x = rand() * W;
return { x, y: ridgeY(x, HILLS[2].y, HILLS[2].components) + 6, s: 0.7 + rand() * 0.6 };
});
function Balloon({ envelope, stripe }: { envelope: string; stripe: string }) {
return (
<>
<path d="M0 -58 C40 -58 50 -22 38 8 C32 22 18 32 12 40 L-12 40 C-18 32 -32 22 -38 8 C-50 -22 -40 -58 0 -58 Z" fill={envelope} />
<path d="M0 -58 C20 -58 26 -22 19 8 C16 22 9 32 6 40 L-6 40 C-9 32 -16 22 -19 8 C-26 -22 -20 -58 0 -58 Z" fill={stripe} />
<path d="M0 -58 C7 -58 9 -22 7 8 C6 22 3 32 2 40 L-2 40 C-3 32 -6 22 -7 8 C-9 -22 -7 -58 0 -58 Z" fill={envelope} />
<ellipse cx="-17" cy="-30" rx="8" ry="16" fill="#fff" opacity="0.18" />
<path d="M-11 40 L-8 54 M11 40 L8 54" stroke="#5a3e2b" strokeWidth="1.2" />
<rect x="-9" y="54" width="18" height="13" rx="2" fill="#8a5a36" />
<rect x="-9" y="54" width="18" height="3" rx="1" fill="#6e4528" />
</>
);
}
const BIRDS = [
{ x: 0, y: 0, delay: 0, d: 'M-6 0 Q-3 -3 0 0 Q3 -3 6 0' },
{ x: 16, y: 8, delay: -0.2, d: 'M-5 0 Q-2.5 -2.5 0 0 Q2.5 -2.5 5 0' },
{ x: 30, y: -4, delay: -0.35, d: 'M-5 0 Q-2.5 -2.5 0 0 Q2.5 -2.5 5 0' },
];
/** A hot-air balloon drifting up and across rolling hills at first light. */
export function BalloonScene(props: SceneProps<BalloonTheme>) {
const sky = useSvgId('sky');
const glow = useSvgId('glow');
return (
<SceneFrame
{...props}
defaultTheme={balloonTheme}
defaultDuration={45}
art={
<>
<defs>
<linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--sky-top)" />
<stop offset="0.45" stopColor="var(--sky-mid)" />
<stop offset="0.7" stopColor="var(--sky-bottom)" />
</linearGradient>
<radialGradient id={glow}>
<stop offset="0" stopColor="var(--sun)" stopOpacity="0.95" />
<stop offset="1" stopColor="var(--sun)" stopOpacity="0" />
</radialGradient>
</defs>
<rect width={W} height="700" fill={`url(#${sky})`} />
<circle cx="112" cy="440" r="140" fill={`url(#${glow})`} />
<circle cx="112" cy="440" r="44" fill="var(--sun)" />
<g fill="#fff" opacity="0.7">
<g className={base.across} style={{ animationDuration: '140s', animationDelay: '-30s' }}>
<ellipse cx="0" cy="120" rx="50" ry="13" />
<ellipse cx="24" cy="110" rx="28" ry="13" />
</g>
<g className={base.across} style={{ animationDuration: '190s', animationDelay: '-140s' }}>
<ellipse cx="0" cy="250" rx="40" ry="10" />
<ellipse cx="-16" cy="243" rx="22" ry="9" />
</g>
</g>
{/* A second balloon far off, just bobbing */}
<g transform="translate(305 300) scale(0.32)">
<g className={styles.float}>
<Balloon envelope="#5b7fb8" stripe="#f4efe2" />
</g>
</g>
<g className={styles.flock} stroke="#5b4a5e" strokeWidth="1.5" fill="none" strokeLinecap="round">
{BIRDS.map((b, i) => (
<g key={i} transform={`translate(${b.x} ${b.y})`}>
<path className={styles.flap} style={{ animationDelay: `${b.delay}s` }} d={b.d} />
</g>
))}
</g>
<path fill={HILLS[0].fill} d={HILL_PATHS[0]} />
<path fill={HILLS[1].fill} d={HILL_PATHS[1]} />
<g className={base.across} style={{ animationDuration: '70s', animationDelay: '-20s' }}>
<ellipse cx="0" cy="535" rx="130" ry="9" fill="#fff" opacity="0.35" />
</g>
<path fill={HILLS[2].fill} d={HILL_PATHS[2]} />
<g fill="#50655b">
{TREES.map((t, i) => (
<g key={i} transform={`translate(${t.x.toFixed(1)} ${t.y.toFixed(1)}) scale(${t.s.toFixed(2)})`}>
<rect x="-1.5" y="-10" width="3" height="10" />
<ellipse cy="-17" rx="7" ry="10" />
</g>
))}
</g>
<g className={base.across} style={{ animationDuration: '95s', animationDelay: '-60s' }}>
<ellipse cx="0" cy="612" rx="150" ry="10" fill="#fff" opacity="0.3" />
</g>
<path fill={HILLS[3].fill} d={HILL_PATHS[3]} />
{/* Main balloon: origin at the middle of the envelope */}
<g className={styles.balloon}>
<g className={styles.sway}>
<Balloon envelope="var(--envelope)" stripe="var(--stripe)" />
<ellipse className={styles.flame} cx="0" cy="46" rx="3" ry="5" fill="#ffb347" />
</g>
</g>
</>
}
/>
);
}
.balloon {
transform: translate(200px, 330px);
animation: rise var(--duration) linear infinite;
animation-delay: calc(var(--duration) * -0.3);
}
.sway { animation: sway 5s ease-in-out infinite alternate; }
.flame { animation: flicker 0.35s ease-in-out infinite alternate; }
.float { animation: float 6s ease-in-out infinite alternate; }
.flock { transform: translate(260px, 190px); animation: flock 34s linear infinite; animation-delay: -12s; }
.flap { animation: flap 0.5s ease-in-out infinite alternate; }
@keyframes rise {
from { transform: translate(-70px, 600px); }
to { transform: translate(470px, 140px); }
}
@keyframes sway { from { transform: rotate(-3deg) translateY(-2px); } to { transform: rotate(3deg) translateY(2px); } }
@keyframes flicker { from { opacity: 0.45; } to { opacity: 1; } }
@keyframes float { from { transform: translateY(-6px); } to { transform: translateY(6px); } }
@keyframes flock { from { transform: translate(440px, 210px); } to { transform: translate(-60px, 170px); } }
@keyframes flap { from { transform: scaleY(1); } to { transform: scaleY(-0.4); } }
// 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; }
}