← Scenes

Boat

<BoatScene /> Sailboat crossing layered waves. Daytime.

Animated · Source · Markdown

Theme
Usage
<BoatScene />
Props and theme tokens
PropTypeDefaultDescription
durationnumber40Seconds for one left-to-right crossing.
themePartial<BoatTheme>—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#9cc9e3
skyBottom--sky-bottom#f3e6d3
sun--sun#fff6dc
water1--water-1#6fa9c2
water2--water-2#4f92b2
water3--water-3#3a7fa3
water4--water-4#2a6a8d
water5--water-5#1d5675
hull--hull#b8322a
Source files

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

components/scenes/boat-scene.tsx
import { W } from './geometry';
import { SceneFrame, WaveLayer, sceneStyles as base, useSvgId, type SceneProps, type WaveLayerProps } from './scene';
import styles from './boat-scene.module.css';

export const boatTheme = {
  skyTop: '#9cc9e3',
  skyBottom: '#f3e6d3',
  sun: '#fff6dc',
  water1: '#6fa9c2',
  water2: '#4f92b2',
  water3: '#3a7fa3',
  water4: '#2a6a8d',
  water5: '#1d5675',
  hull: '#b8322a',
};
export type BoatTheme = typeof boatTheme;

const BACK_WAVES: WaveLayerProps[] = [
  { y: 382, components: [[2.5, 80]],  fill: 'var(--water-1)', drift: 16, swell: 5.0, delay: 0 },
  { y: 430, components: [[5, 100]],   fill: 'var(--water-2)', drift: 12, swell: 4.2, delay: 0.9, reverse: true },
];
const FRONT_WAVES: WaveLayerProps[] = [
  { y: 482, components: [[7, W / 3]], fill: 'var(--water-3)', drift: 9, swell: 3.6, delay: 1.8 },
  { y: 560, components: [[9, 200]],   fill: 'var(--water-4)', drift: 8, swell: 4.6, delay: 2.7, reverse: true },
  { y: 640, components: [[11, 200]],  fill: 'var(--water-5)', drift: 6, swell: 3.9, delay: 3.6 },
];

/** A small sailboat crossing gentle waves on a calm afternoon. */
export function BoatScene(props: SceneProps<BoatTheme>) {
  const sky = useSvgId('sky');
  const glow = useSvgId('glow');

  return (
    <SceneFrame
      {...props}
      defaultTheme={boatTheme}
      defaultDuration={40}
      art={
        <>
          <defs>
            <linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--sky-top)" />
              <stop offset="1" 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="290" cy="250" r="110" fill={`url(#${glow})`} />
          <circle cx="290" cy="250" r="34" fill="var(--sun)" />

          <g fill="#fff" opacity="0.75">
            <g className={base.across} style={{ animationDuration: '120s', animationDelay: '-50s' }}>
              <ellipse cx="0" cy="140" rx="46" ry="14" />
              <ellipse cx="22" cy="130" rx="28" ry="14" />
            </g>
            <g className={base.across} style={{ animationDuration: '160s', animationDelay: '-120s' }}>
              <ellipse cx="0" cy="210" rx="36" ry="10" />
              <ellipse cx="-14" cy="203" rx="20" ry="10" />
            </g>
          </g>

          {BACK_WAVES.map((w, i) => <WaveLayer key={i} {...w} />)}

          <g fill="var(--sun)">
            <rect className={base.glint} x="262" y="396" width="56" height="2.5" rx="1.25" style={{ animationDuration: '1.7s' }} />
            <rect className={base.glint} x="274" y="410" width="34" height="2" rx="1" style={{ animationDuration: '2.3s', animationDelay: '-1s' }} />
            <rect className={base.glint} x="252" y="422" width="22" height="2" rx="1" style={{ animationDuration: '1.4s', animationDelay: '-0.5s' }} />
          </g>

          {/* Boat: origin is the waterline at mid-hull, bow pointing right */}
          <g className={styles.boat}>
            <path
              className={styles.wake}
              d="M-46 2 Q-90 6 -150 3 M-40 9 Q-80 14 -130 13"
              fill="none"
              stroke="#fff"
              strokeOpacity="0.55"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeDasharray="12 6"
            />
            <g className={styles.bob}>
              <path d="M8 -80 L8 -10 L-38 -10 Z" fill="#faf7f0" />
              <path d="M12 -74 L12 -10 L44 -10 Z" fill="#ece5d8" />
              <rect x="8" y="-84" width="3" height="78" fill="#5a3e2b" />
              <path d="M11 -84 L25 -80 L11 -76 Z" fill="#e8b33a" />
              <rect x="-32" y="-18" width="26" height="12" rx="2" fill="#f4f1ea" />
              <rect x="-27" y="-15" width="5" height="4" fill="#7fa6b8" />
              <rect x="-18" y="-15" width="5" height="4" fill="#7fa6b8" />
              <path d="M-50 -6 L52 -6 Q46 10 30 15 L-40 15 Q-47 8 -50 -6 Z" fill="var(--hull)" />
              <path d="M-50 -6 L52 -6 L50.5 -2 L-49 -2 Z" fill="#f4f1ea" />
            </g>
          </g>

          {FRONT_WAVES.map((w, i) => <WaveLayer key={i} {...w} />)}
        </>
      }
    />
  );
}
components/scenes/boat-scene.module.css
.boat {
  transform: translate(200px, 478px); /* resting spot for reduced motion */
  animation: sail var(--duration) linear infinite;
  animation-delay: calc(var(--duration) * -0.2); /* start already in view */
}
.bob  { animation: bob 3.4s ease-in-out infinite alternate; }
.wake { animation: wake 1.2s linear infinite; }

@keyframes sail {
  from { transform: translate(-90px, 478px); }
  to   { transform: translate(490px, 478px); }
}
@keyframes bob {
  0%   { transform: translateY(-2px) rotate(-2.5deg); }
  50%  { transform: translateY(2px) rotate(1deg); }
  100% { transform: translateY(-1px) rotate(3deg); }
}
@keyframes wake { to { stroke-dashoffset: 18; } }
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; }
}