← Scenes

Kite

<KiteScene /> A child flying a kite from a grassy hill. Breezy afternoon.

Animated · Source · Markdown

Theme
Usage
<KiteScene />
Props and theme tokens
PropTypeDefaultDescription
durationnumber16Seconds for one loop of the kite's wander.
themePartial<KiteTheme>—Colour overrides. Keys listed under theme tokens.
pausedbooleanfalseFreezes all motion. Also paused automatically while off-screen.
classNamestring—Applied to the root element.
styleCSSProperties—Merged into the root element style, after theme variables.
childrenReactNode—Rendered above the art in an absolutely positioned layer.
Theme keyCSS variableDefault
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.

components/scenes/kite-scene.tsx
// <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>
        </>
      }
    />
  );
}
components/scenes/kite-scene.module.css
/* 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); }
}
components/scenes/geometry.ts
// 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;
  };
}
components/scenes/scene.tsx
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>
  );
}
components/scenes/scene-frame.tsx
'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>
  );
}
components/scenes/scene.module.css
.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; }
}