← Scenes

Autumn

<AutumnScene /> A cyclist on a country road as maple leaves fall.

Animated · Source · Markdown

Theme
Usage
<AutumnScene />
Props and theme tokens
PropTypeDefaultDescription
durationnumber30Seconds for the cyclist to cross the frame.
themePartial<AutumnTheme>—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#a9cde6
skyBottom--sky-bottom#fbe7c6
sun--sun#fff4d6
hill1--hill-1#d9b878
hill2--hill-2#b79a52
field--field#9cab5a
road--road#d8c3a0
leaf1--leaf-1#e07a3a
leaf2--leaf-2#c84a32
leaf3--leaf-3#e8b84a
barn--barn#b8402e
coat--coat#4f7d9c
scarf--scarf#e0503c
Source files

Copy into components/scenes/. The shared files are needed once for all scenes.

components/scenes/autumn-scene.tsx
// <AutumnScene>: a cyclist riding a winding country road on an autumn afternoon while maple leaves
// tumble down. A red barn sits on the hill among orange and gold trees; a fence runs along the
// verge. `duration` is seconds for the cyclist to cross the frame (default 30).

import type { CSSProperties } from 'react';
import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry';
import { SceneFrame, useSvgId, type SceneProps } from './scene';
import styles from './autumn-scene.module.css';

export const autumnTheme = {
  skyTop: '#a9cde6',
  skyBottom: '#fbe7c6',
  sun: '#fff4d6',
  hill1: '#d9b878',
  hill2: '#b79a52',
  field: '#9cab5a',
  road: '#d8c3a0',
  leaf1: '#e07a3a',
  leaf2: '#c84a32',
  leaf3: '#e8b84a',
  barn: '#b8402e',
  coat: '#4f7d9c',
  scarf: '#e0503c',
};
export type AutumnTheme = typeof autumnTheme;

const FAR: Ridge = [[14, 400, 1.6], [6, 200]];
const MID: Ridge = [[18, 400, 0.2], [7, 100, 2]];
const ROAD_Y = 590;
const ROAD: Ridge = [[10, 400, 2.2], [3, 200, 0.4]];
const ROAD_WIDTH = 30;

// The road as a ribbon between two copies of the same curve.
const ROAD_PATH = (() => {
  const top: string[] = [];
  const bottom: string[] = [];
  for (let x = 0; x <= W; x += 8) {
    top.push(`${x} ${ridgeY(x, ROAD_Y, ROAD).toFixed(1)}`);
    bottom.unshift(`${x} ${(ridgeY(x, ROAD_Y, ROAD) + ROAD_WIDTH).toFixed(1)}`);
  }
  return `M${top.join(' L')} L${bottom.join(' L')} Z`;
})();

// The cyclist's height at eleven points along the ride, from the road's own curve.
const STEPS = Array.from({ length: 11 }, (_, i) => -80 + i * 56);
const RIDE_STYLE = Object.fromEntries(
  STEPS.map((x, i) => [`--y${i}`, `${(ridgeY(x, ROAD_Y, ROAD) + ROAD_WIDTH * 0.62).toFixed(1)}px`]),
) as CSSProperties;

const MAPLE = 'M0 -9 L2 -3.5 L7.5 -5.5 L4.5 0 L8.5 3 L2.5 3 L1.5 8 L0 4.5 L-1.5 8 L-2.5 3 L-8.5 3 L-4.5 0 L-7.5 -5.5 L-2 -3.5 Z';
const LEAF_COLORS = ['var(--leaf-1)', 'var(--leaf-2)', 'var(--leaf-3)'];

const rand = seeded(29);
// Leaves rest at scattered (x, y); the fall runs relative to that point, drifting right on the wind.
const LEAVES = Array.from({ length: 28 }, (_, i) => {
  const y = rand() * 700;
  return {
    x: -90 + rand() * (W + 90),
    y,
    size: 0.8 + rand() * 0.7,
    fall: 10 + rand() * 8,
    delay: rand() * 18,
    spin: (4 + rand() * 6) * (rand() > 0.5 ? 1 : -1),
    flutter: 0.9 + rand() * 1.1,
    color: LEAF_COLORS[i % 3],
  };
});

// Round autumn trees on the middle hill, in three colours.
const TREES = Array.from({ length: 14 }, (_, i) => {
  const x = 120 + rand() * 300;
  return { x, y: ridgeY(x, 520, MID) + 4, r: 9 + rand() * 9, color: LEAF_COLORS[i % 3] };
});

const FALLEN = Array.from({ length: 24 }, (_, i) => ({
  x: rand() * W,
  y: 646 + rand() * 50,
  rot: rand() * 360,
  color: LEAF_COLORS[i % 3],
}));

/** A cyclist with a flower basket, facing right, origin on the road under the bottom bracket. */
function Cyclist() {
  const wheel = (x: number) => (
    <g transform={`translate(${x} -11)`}>
      <circle r="11" fill="none" stroke="#3d4a57" strokeWidth="2.2" />
      <g className={styles.spin}>
        <path d="M-10 0 H10 M0 -10 V10 M-7 -7 L7 7 M-7 7 L7 -7" stroke="#3d4a57" strokeWidth="0.8" />
      </g>
    </g>
  );
  return (
    <g className={styles.bob}>
      {wheel(-17)}
      {wheel(19)}
      {/* Far leg, behind the frame */}
      <g transform="translate(-6 -32)">
        <path className={`${styles.leg} ${styles.legB}`} d="M0 0 L8 10 L4 21" fill="none" stroke="#2f3b46" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" />
      </g>
      <path d="M-17 -11 L-2 -11 L10 -27 L-6 -27 Z M-2 -11 L-8 -30 M10 -27 L19 -11 M10 -27 L9 -34" fill="none" stroke="#3d4a57" strokeWidth="2.2" strokeLinejoin="round" />
      <path d="M-12 -31 H-4" stroke="#3d4a57" strokeWidth="2.6" strokeLinecap="round" />
      <path d="M6 -35 H13" stroke="#3d4a57" strokeWidth="2" strokeLinecap="round" />
      {/* Basket of flowers on the handlebars */}
      <path d="M12 -32 H24 L22 -24 H14 Z" fill="#b8875a" />
      <g fill="var(--leaf-3)">
        <circle cx="15" cy="-34" r="2.2" />
        <circle cx="19" cy="-35" r="2.4" fill="var(--scarf)" />
        <circle cx="22" cy="-33.5" r="2" />
      </g>
      {/* Rider */}
      <path d="M-8 -32 Q-6 -46 2 -50 L8 -46 Q2 -40 -1 -32 Z" fill="var(--coat)" />
      <path d="M4 -46 L11 -36" stroke="var(--coat)" strokeWidth="3.4" strokeLinecap="round" />
      <circle cx="6" cy="-55" r="5" fill="#f1c7a3" />
      <path d="M1 -57 Q2 -62 7 -61 Q11 -60 10 -56 Q6 -59 1 -57 Z" fill="#5a3e2b" />
      <g className={styles.scarf}>
        <path d="M3 -50 Q-6 -50 -14 -46" fill="none" stroke="var(--scarf)" strokeWidth="3" strokeLinecap="round" />
      </g>
      {/* Near leg, in front of the frame */}
      <g transform="translate(-6 -32)">
        <path className={styles.leg} d="M0 0 L8 10 L4 21" fill="none" stroke="#3d4a57" strokeWidth="3.6" strokeLinecap="round" strokeLinejoin="round" />
      </g>
    </g>
  );
}

/** A cyclist on a country road on an autumn afternoon, maple leaves tumbling down. */
export function AutumnScene(props: SceneProps<AutumnTheme>) {
  const sky = useSvgId('sky');
  const glow = useSvgId('glow');

  return (
    <SceneFrame
      {...props}
      defaultTheme={autumnTheme}
      defaultDuration={30}
      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="300" cy="300" r="130" fill={`url(#${glow})`} />
          <circle cx="300" cy="300" r="30" fill="var(--sun)" />

          {/* Geese heading south in a V */}
          <g className={styles.geese} fill="none" stroke="#5b5a63" strokeWidth="1.3" strokeLinecap="round">
            {[
              [0, 0],
              [-10, -6],
              [-20, -12],
              [-10, 6],
              [-20, 12],
            ].map(([x, y], i) => (
              <path key={i} className={styles.flap} style={{ animationDelay: `${-i * 0.15}s` }} d={`M${x - 4} ${y} q2 -3 4 0 q2 -3 4 0`} />
            ))}
          </g>

          <path fill="var(--hill-1)" d={bandPath(450, FAR)} />
          <path fill="var(--hill-2)" d={bandPath(520, MID)} />

          {/* Red barn with a silo on the hill */}
          <g transform={`translate(70 ${(ridgeY(70, 520, MID) + 6).toFixed(1)})`}>
            <rect x="34" y="-56" width="14" height="56" fill="#c9c2b6" />
            <path d="M34 -56 Q41 -66 48 -56 Z" fill="#8a8a8a" />
            <path d="M-26 0 V-30 L0 -46 L26 -30 V0 Z" fill="var(--barn)" />
            <path d="M-28 -29 L0 -48 L28 -29" fill="none" stroke="#ffffff" strokeWidth="2.4" />
            <rect x="-9" y="-20" width="18" height="20" fill="#ffffff" />
            <path d="M-9 -20 L9 0 M9 -20 L-9 0" stroke="var(--barn)" strokeWidth="2" />
            <rect x="-4" y="-38" width="8" height="7" fill="#ffffff" />
          </g>

          {TREES.map((t, i) => (
            <g key={i}>
              <rect x={(t.x - 1.5).toFixed(1)} y={(t.y - t.r).toFixed(1)} width="3" height={t.r.toFixed(1)} fill="#6b4a32" />
              <circle cx={t.x.toFixed(1)} cy={(t.y - t.r * 1.4).toFixed(1)} r={t.r.toFixed(1)} fill={t.color} />
              <circle cx={(t.x + t.r * 0.3).toFixed(1)} cy={(t.y - t.r * 1.2).toFixed(1)} r={(t.r * 0.6).toFixed(1)} fill="#000" opacity="0.1" />
            </g>
          ))}

          {/* Field and road */}
          <path fill="var(--field)" d={bandPath(566, [[8, 400, 1], [3, 100]])} />
          <path fill="var(--road)" d={ROAD_PATH} />

          <g className={styles.ride} style={RIDE_STYLE}>
            <g transform="scale(1.3)">
              <Cyclist />
            </g>
          </g>

          {/* Near verge with a fence and fallen leaves */}
          <path fill="var(--field)" d={bandPath(644, [[6, 400, 2.8], [3, 80]])} />
          <g stroke="#8a6a4a" strokeWidth="3" strokeLinecap="round">
            {Array.from({ length: 11 }, (_, i) => {
              const x = i * 40 + 6;
              const y = ridgeY(x, 644, [[6, 400, 2.8], [3, 80]]) + 4;
              return <path key={i} d={`M${x} ${y.toFixed(1)} V${(y - 26).toFixed(1)}`} />;
            })}
          </g>
          <path
            d={Array.from({ length: 11 }, (_, i) => {
              const x = i * 40 + 6;
              const y = ridgeY(x, 644, [[6, 400, 2.8], [3, 80]]) + 4;
              return `${i ? 'L' : 'M'}${x} ${(y - 20).toFixed(1)}`;
            }).join(' ')}
            fill="none"
            stroke="#8a6a4a"
            strokeWidth="2.4"
          />
          {FALLEN.map((f, i) => (
            <path key={i} d={MAPLE} fill={f.color} transform={`translate(${f.x.toFixed(1)} ${f.y.toFixed(1)}) rotate(${f.rot.toFixed(0)}) scale(0.7)`} />
          ))}

          {/* Leaves on the wind, drawn last so they tumble in front of everything */}
          {LEAVES.map((l, i) => (
            <g key={i} transform={`translate(${l.x.toFixed(1)} ${l.y.toFixed(1)})`}>
              <g
                className={styles.fall}
                style={
                  {
                    '--from': `${(-l.y - 20).toFixed(1)}px`,
                    '--to': `${(720 - l.y).toFixed(1)}px`,
                    animationDuration: `${l.fall.toFixed(2)}s`,
                    animationDelay: `${(-l.delay).toFixed(2)}s`,
                  } as CSSProperties
                }
              >
                <g className={styles.sway}>
                  <g
                    className={styles.tumble}
                    style={{ animationDuration: `${Math.abs(l.spin).toFixed(2)}s`, animationDirection: l.spin < 0 ? 'reverse' : 'normal' }}
                  >
                    <g className={styles.flutter} style={{ animationDuration: `${l.flutter.toFixed(2)}s` }}>
                      <path d={MAPLE} fill={l.color} transform={`scale(${l.size.toFixed(2)})`} />
                    </g>
                  </g>
                </g>
              </g>
            </g>
          ))}
        </>
      }
    />
  );
}
components/scenes/autumn-scene.module.css
/* The cyclist rides across once per --duration, at the road's height at eleven points along the
   way (--y0 … --y10, computed from the same curve that draws the road). */
.ride {
  transform: translate(200px, var(--y5)); /* resting spot for reduced motion */
  animation: ride var(--duration) linear infinite;
  animation-delay: calc(var(--duration) * -0.4);
}

/* Wheels spin, legs pedal in opposition, the bike rocks over the gravel, the scarf streams. */
.spin {
  transform-box: fill-box;
  transform-origin: center;
  animation: spin 0.9s linear infinite;
}
.leg {
  transform-box: fill-box;
  transform-origin: 0% 0%;
  animation: pedal 0.9s ease-in-out infinite alternate;
}
.legB { animation-delay: -0.9s; }
.bob { animation: bob 0.45s ease-in-out infinite alternate; }
.scarf {
  transform-box: fill-box;
  transform-origin: 100% 50%;
  animation: scarf 0.6s ease-in-out infinite alternate;
}

/* Geese cross high up, wings beating. */
.geese {
  transform: translate(200px, 150px);
  animation: geese 40s linear infinite;
  animation-delay: -12s;
}
.flap {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: flap 0.5s ease-in-out infinite alternate;
}

/* Leaves fall past their resting point (set through --from and --to), carried right on the wind,
   swaying, tumbling and fluttering (a flip faked with scaleY). */
.fall    { animation: fall linear infinite; }
.sway    { animation: sway 2.6s ease-in-out infinite alternate; }
.tumble  { animation: spin linear infinite; }
.flutter { animation: flutter ease-in-out infinite alternate; }

@keyframes ride {
  0%   { transform: translate(-80px, var(--y0)); }
  10%  { transform: translate(-24px, var(--y1)); }
  20%  { transform: translate(32px, var(--y2)); }
  30%  { transform: translate(88px, var(--y3)); }
  40%  { transform: translate(144px, var(--y4)); }
  50%  { transform: translate(200px, var(--y5)); }
  60%  { transform: translate(256px, var(--y6)); }
  70%  { transform: translate(312px, var(--y7)); }
  80%  { transform: translate(368px, var(--y8)); }
  90%  { transform: translate(424px, var(--y9)); }
  100% { transform: translate(480px, var(--y10)); }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pedal {
  from { transform: rotate(-22deg); }
  to   { transform: rotate(16deg); }
}
@keyframes bob {
  from { transform: translateY(0); }
  to   { transform: translateY(-0.8px); }
}
@keyframes scarf {
  from { transform: rotate(-6deg) scaleX(0.92); }
  to   { transform: rotate(6deg) scaleX(1.06); }
}
@keyframes geese {
  from { transform: translate(470px, 170px); }
  to   { transform: translate(-60px, 120px); }
}
@keyframes flap {
  from { transform: scaleY(1); }
  to   { transform: scaleY(-0.5); }
}
@keyframes fall {
  from { transform: translate(0, var(--from)); }
  to   { transform: translate(90px, var(--to)); }
}
@keyframes sway {
  from { transform: translateX(-10px); }
  to   { transform: translateX(10px); }
}
@keyframes flutter { from { transform: scaleY(1); } to { transform: scaleY(0.3); } }
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; }
}