← Scenes
Kite
<KiteScene /> A child flying a kite from a grassy hill. Breezy afternoon.
<KiteScene />Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 16 | Seconds for one loop of the kite's wander. |
theme | Partial<KiteTheme> | — | 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 | #8cc4ea |
skyBottom | --sky-bottom | #eaf5f6 |
sun | --sun | #fff7d6 |
hill1 | --hill-1 | #b7dcb0 |
hill2 | --hill-2 | #8cc98a |
hill3 | --hill-3 | #64b06a |
grass | --grass | #4f9a58 |
kite | --kite | #e4573d |
kiteAlt | --kite-alt | #f3c64f |
tail | --tail | #5b7fb8 |
string | --string | #ffffff |
windmill | --windmill | #f4f1ea |
shirt | --shirt | #5b7fb8 |
Source files
Copy into components/scenes/. The shared files are needed once for all scenes.
// <KiteScene>: a child flying a diamond kite from a grassy hill on a breezy spring afternoon.
// The kite wanders a loop in the sky with its ribbon tail waving; the string stays pinned to the
// child's hand (it turns and stretches with the kite). A windmill turns on the far hill.
// `duration` is seconds for one loop of the kite's wander (default 16).
import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry';
import { SceneFrame, sceneStyles as base, useSvgId, type SceneProps } from './scene';
import styles from './kite-scene.module.css';
export const kiteTheme = {
skyTop: '#8cc4ea',
skyBottom: '#eaf5f6',
sun: '#fff7d6',
hill1: '#b7dcb0',
hill2: '#8cc98a',
hill3: '#64b06a',
grass: '#4f9a58',
kite: '#e4573d',
kiteAlt: '#f3c64f',
tail: '#5b7fb8',
string: '#ffffff',
windmill: '#f4f1ea',
shirt: '#5b7fb8',
};
export type KiteTheme = typeof kiteTheme;
const HILL1: Ridge = [[16, 400, 1], [6, 200]];
const HILL2: Ridge = [[18, 400, 3], [7, 100, 1]];
const HILL3: Ridge = [[14, 400, 2.2], [5, 200, 1]];
// The child's hand, where the string is tied; the kite's first keyframe in kite-scene.module.css.
const HAND = { x: 78, y: 594 };
const KITE0 = { x: 232, y: 236 };
const rand = seeded(23);
const FLOWERS = Array.from({ length: 26 }, () => {
const x = rand() * W;
return { x, y: ridgeY(x, 552, HILL2) + 6 + rand() * 30, c: rand() > 0.5 ? 'var(--kite-alt)' : '#ffffff' };
});
const TUFTS = Array.from({ length: 30 }, (_, i) => {
const x = i * 14 + rand() * 8;
return { x, y: ridgeY(x, 624, HILL3) + 3 + rand() * 50, h: 7 + rand() * 7, delay: rand() * 3 };
});
/** One bow of the kite's tail; each holds the next, so the wave travels down the tail. */
function Tail({ depth }: { depth: number }) {
if (depth === 0) return null;
return (
<g className={styles.tail} style={{ animationDelay: `${-depth * 0.18}s` }}>
<path d="M0 0 Q4 7 0 14" fill="none" stroke="var(--tail)" strokeWidth="1.4" />
<path d="M-5 11 L0 14 L-5 17 Z M5 11 L0 14 L5 17 Z" fill={depth % 2 ? 'var(--kite-alt)' : 'var(--kite)'} />
<g transform="translate(0 14)">
<Tail depth={depth - 1} />
</g>
</g>
);
}
/** A child flying a kite from a grassy hill on a breezy afternoon. */
export function KiteScene(props: SceneProps<KiteTheme>) {
const sky = useSvgId('sky');
const glow = useSvgId('glow');
return (
<SceneFrame
{...props}
defaultTheme={kiteTheme}
defaultDuration={16}
art={
<>
<defs>
<linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--sky-top)" />
<stop offset="0.7" 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="330" cy="120" r="100" fill={`url(#${glow})`} />
<circle cx="330" cy="120" r="28" fill="var(--sun)" />
<g fill="#fff">
<g className={base.across} style={{ animationDuration: '70s', animationDelay: '-10s' }} opacity="0.9">
<ellipse cx="0" cy="170" rx="44" ry="13" />
<ellipse cx="18" cy="160" rx="26" ry="14" />
<ellipse cx="-20" cy="164" rx="16" ry="9" />
</g>
<g className={base.across} style={{ animationDuration: '95s', animationDelay: '-60s' }} opacity="0.75">
<ellipse cx="0" cy="330" rx="36" ry="10" />
<ellipse cx="-12" cy="322" rx="20" ry="10" />
</g>
<g className={base.across} style={{ animationDuration: '120s', animationDelay: '-30s' }} opacity="0.6">
<ellipse cx="0" cy="420" rx="30" ry="8" />
</g>
</g>
<path fill="var(--hill-1)" d={bandPath(480, HILL1)} />
{/* Windmill on the far hill */}
<g transform={`translate(318 ${ridgeY(318, 480, HILL1).toFixed(1)})`}>
<path d="M-7 4 L-4 -34 H4 L7 4 Z" fill="var(--windmill)" />
<path d="M-6 -33 L0 -42 L6 -33 Z" fill="var(--kite)" />
<g transform="translate(0 -34)">
<g className={styles.blades} fill="var(--windmill)">
{[0, 90, 180, 270].map(a => (
<rect key={a} x="-2" y="-26" width="4" height="24" rx="1" transform={`rotate(${a})`} />
))}
<circle r="2.6" fill="var(--kite)" />
</g>
</g>
</g>
<path fill="var(--hill-2)" d={bandPath(552, HILL2)} />
<g>
{FLOWERS.map((f, i) => (
<circle key={i} cx={f.x.toFixed(1)} cy={f.y.toFixed(1)} r="1.8" fill={f.c} />
))}
</g>
<path fill="var(--hill-3)" d={bandPath(624, HILL3)} />
{/* String: drawn toward the kite's first position, then turned and stretched about the hand */}
<g transform={`translate(${HAND.x} ${HAND.y})`}>
<g className={styles.string}>
<path
d={`M0 0 Q${((KITE0.x - HAND.x) * 0.55).toFixed(1)} ${((KITE0.y - HAND.y) * 0.4).toFixed(1)} ${KITE0.x - HAND.x} ${KITE0.y - HAND.y}`}
fill="none"
stroke="var(--string)"
strokeOpacity="0.85"
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
/>
</g>
</g>
{/* Kite: origin at the point where the string ties on */}
<g className={styles.kite}>
<g className={styles.dance}>
<g transform="translate(0 30)">
<Tail depth={6} />
</g>
<path d="M0 -34 L22 -4 L0 30 L-22 -4 Z" fill="var(--kite)" />
<path d="M0 -34 L22 -4 L0 -4 Z M0 -4 L-22 -4 L0 30 Z" fill="var(--kite-alt)" />
<path d="M0 -34 V30 M-22 -4 H22" stroke="#7a4a2e" strokeOpacity="0.6" strokeWidth="1.4" />
</g>
</g>
{/* The child, holding the string up */}
<g transform={`translate(${HAND.x - 14} ${HAND.y + 2})`}>
<path d="M14 -2 L4 10" stroke="#f1c7a3" strokeWidth="3" strokeLinecap="round" />
<path d="M-6 30 L-2 6 Q2 2 6 6 L10 30 Z" fill="var(--shirt)" />
<path d="M-2 30 V40 M5 30 V40" stroke="#3d4a57" strokeWidth="3" strokeLinecap="round" />
<circle cx="2" cy="-4" r="7" fill="#f1c7a3" />
<path d="M-5 -6 Q-4 -13 3 -12 Q9 -12 9 -5 Q5 -9 -5 -6 Z" fill="#5a3e2b" />
<path className={styles.scarf} d="M-1 6 Q-10 6 -16 10" stroke="var(--kite)" strokeWidth="3" strokeLinecap="round" fill="none" />
</g>
<g fill="none" stroke="var(--grass)" strokeWidth="2" strokeLinecap="round">
{TUFTS.map((t, i) => (
<g key={i} transform={`translate(${t.x.toFixed(1)} ${t.y.toFixed(1)})`}>
<path
className={styles.blade}
style={{ animationDelay: `${(-t.delay).toFixed(2)}s` }}
d={`M-3 0 Q-4 ${(-t.h * 0.6).toFixed(1)} -6 ${(-t.h).toFixed(1)} M0 0 Q1 ${(-t.h * 0.7).toFixed(1)} 2 ${(-t.h - 3).toFixed(1)} M3 0 Q5 ${(-t.h * 0.5).toFixed(1)} 7 ${(-t.h * 0.8).toFixed(1)}`}
/>
</g>
))}
</g>
</>
}
/>
);
}
/* The kite wanders a loop once per --duration. The string's keyframes are the same positions seen
from the hand (angle change and length ratio from the first position), on the same timing, so
its far end stays under the kite. */
.kite {
transform: translate(232px, 236px); /* resting spot for reduced motion */
animation: wander var(--duration) ease-in-out infinite;
}
.string { animation: string var(--duration) ease-in-out infinite; }
/* Little gusts on top of the wander: a tilt and a bob. */
.dance { animation: dance 2.6s ease-in-out infinite alternate; }
/* Each bow swings a little from the one above it, with a delay, so a wave runs down the tail. */
.tail { animation: tail 1.1s ease-in-out infinite alternate; }
.blades { animation: spin 6s linear infinite; }
.blade {
transform-box: fill-box;
transform-origin: 50% 100%;
animation: blow 2.4s ease-in-out infinite alternate;
}
.scarf {
transform-box: fill-box;
transform-origin: 100% 0%;
animation: flap 0.8s ease-in-out infinite alternate;
}
@keyframes wander {
0% { transform: translate(232px, 236px); }
14% { transform: translate(262px, 214px); }
30% { transform: translate(292px, 226px); }
46% { transform: translate(304px, 256px); }
62% { transform: translate(276px, 276px); }
78% { transform: translate(244px, 266px); }
90% { transform: translate(222px, 252px); }
100% { transform: translate(232px, 236px); }
}
@keyframes string {
0% { transform: rotate(0deg) scale(1); }
14% { transform: rotate(2.56deg) scale(1.0834); }
30% { transform: rotate(6.9deg) scale(1.0923); }
46% { transform: rotate(10.49deg) scale(1.0433); }
62% { transform: rotate(8.63deg) scale(0.9612); }
78% { transform: rotate(3.57deg) scale(0.9433); }
90% { transform: rotate(-0.44deg) scale(0.9522); }
100% { transform: rotate(0deg) scale(1); }
}
@keyframes dance {
from { transform: rotate(-9deg) translateY(-2px); }
to { transform: rotate(7deg) translateY(3px); }
}
@keyframes tail {
from { transform: rotate(-14deg); }
to { transform: rotate(14deg); }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blow {
from { transform: skewX(-6deg); }
to { transform: skewX(10deg); }
}
@keyframes flap {
from { transform: rotate(-6deg) scaleX(0.9); }
to { transform: rotate(8deg) scaleX(1.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; }
}