← 3D

Lagoon

<Lagoon3D /> Sailboat circling a palm-tree island on a low-poly sea.

Animated · Source · Markdown

Theme
Usage
<Lagoon3D />
Props and theme tokens
PropTypeDefaultDescription
themePartial<LagoonTheme>—Colour overrides. Applied live without rebuilding the scene.
speednumber1Animation speed multiplier.
pausedbooleanfalseFreezes the animation. Also stops rendering while off-screen or in a hidden tab.
classNamestring—Applied to the root element (4:3 by default; override the aspect ratio or height here).
styleCSSProperties—Merged into the root element style.
childrenReactNode—Rendered above the canvas, filling it.
Theme keyCSS variableDefault
Source files

Copy into components/three/. The shared files are needed once for all 3d. Also run npm i three.

components/three/lagoon-3d.tsx
'use client';

// <Lagoon3D>: a sailboat circling a palm-tree island on a low-poly sea. The boat samples
// the same wave function as the water, so it pitches and rolls with the swell. three.js.

import * as THREE from 'three';
import { ThreeFrame, seeded, type SceneSetup, type ThreeSceneProps } from './three-frame';

export const lagoonTheme = {
  skyTop: '#7fb6e0',
  horizon: '#dcebf2',
  water: '#2f86a8',
  sand: '#f0d9a6',
  grass: '#6cbf6a',
  palm: '#3f9d5a',
  trunk: '#9a6b45',
  hull: '#e4573d',
  sail: '#fbf8f1',
  cloud: '#ffffff',
};
export type LagoonTheme = typeof lagoonTheme;

/** Height of the sea at (x, z) and time t. Shared by the water mesh and the boat. */
const waveHeight = (x: number, z: number, t: number) =>
  0.13 * Math.sin(0.55 * x + 1.1 * t) + 0.09 * Math.sin(0.8 * z - 0.8 * t + 1.3) + 0.05 * Math.sin(1.6 * (x + z) + 1.9 * t);

const BOAT_ORBIT = 4.3;

function makeBoat(mats: Record<string, THREE.Material>) {
  const boat = new THREE.Group();
  const outline = new THREE.Shape();
  outline.moveTo(-0.9, -0.32);
  outline.lineTo(0.35, -0.34);
  outline.quadraticCurveTo(0.8, -0.26, 1.05, 0);
  outline.quadraticCurveTo(0.8, 0.26, 0.35, 0.34);
  outline.lineTo(-0.9, 0.32);
  outline.closePath();
  const hullGeo = new THREE.ExtrudeGeometry(outline, { depth: 0.4, bevelEnabled: false, curveSegments: 6 });
  hullGeo.rotateX(Math.PI / 2);
  const p = hullGeo.attributes.position;
  for (let i = 0; i < p.count; i++) p.setZ(i, p.getZ(i) * (1 + 0.9 * p.getY(i) / 0.4)); // taper toward the keel
  hullGeo.translate(0, 0.22, 0);
  hullGeo.computeVertexNormals();
  boat.add(new THREE.Mesh(hullGeo, mats.hull));

  const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.03, 1.5, 5), mats.trunk);
  mast.position.set(0.15, 0.95, 0);
  boat.add(mast);

  const sail = (points: number[]) => {
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(points, 3));
    g.computeVertexNormals();
    return new THREE.Mesh(g, mats.sail);
  };
  boat.add(sail([0.13, 0.35, 0, 0.13, 1.65, 0, -0.75, 0.35, 0]));
  boat.add(sail([0.18, 0.4, 0, 0.18, 1.45, 0, 0.95, 0.35, 0]));
  return boat;
}

function makeGull(mat: THREE.Material) {
  const gull = new THREE.Group();
  // Each wing is a swept triangle hinged at the body.
  const wing = new THREE.BufferGeometry();
  wing.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0.07, 0, 0, -0.06, 0.26, 0, -0.1], 3));
  wing.computeVertexNormals();
  const left = new THREE.Mesh(wing, mat);
  const right = new THREE.Mesh(wing, mat);
  right.scale.x = -1;
  const body = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.18, 4), mat);
  body.rotation.x = Math.PI / 2;
  gull.add(left, right, body);
  return { gull, left, right };
}

const setup: SceneSetup<LagoonTheme> = ({ scene, camera, theme }) => {
  const rand = seeded(19);
  camera.position.set(0, 6, 13.5);
  camera.lookAt(0, 0.4, 0);
  scene.fog = new THREE.Fog(theme.horizon, 16, 34);

  const flat = (color: string, extra: THREE.MeshStandardMaterialParameters = {}) =>
    new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.8, ...extra });
  const mats = {
    water: flat(theme.water, { roughness: 0.35, metalness: 0.05 }),
    sand: flat(theme.sand),
    grass: flat(theme.grass),
    palm: flat(theme.palm, { side: THREE.DoubleSide }),
    trunk: flat(theme.trunk),
    hull: flat(theme.hull),
    sail: flat(theme.sail, { side: THREE.DoubleSide }),
    cloud: flat(theme.cloud, { roughness: 1 }),
    gull: flat('#ffffff', { side: THREE.DoubleSide }),
  };

  const hemi = new THREE.HemisphereLight('#ffffff', theme.water, 1.4);
  const sun = new THREE.DirectionalLight('#fff4e0', 2.2);
  sun.position.set(-6, 9, 5);
  scene.add(hemi, sun);

  // Sea: a flat-shaded grid whose heights are rewritten each frame.
  const waterGeo = new THREE.PlaneGeometry(60, 60, 72, 72);
  waterGeo.rotateX(-Math.PI / 2);
  const waterPos = waterGeo.attributes.position;
  scene.add(new THREE.Mesh(waterGeo, mats.water));

  // Island: a flattened low-poly dome of sand with a grassy cap and a palm.
  const island = new THREE.Group();
  const sandGeo = new THREE.SphereGeometry(2, 9, 5, 0, Math.PI * 2, 0, Math.PI / 2);
  sandGeo.scale(1, 0.32, 1);
  const grassGeo = new THREE.SphereGeometry(1.35, 8, 4, 0, Math.PI * 2, 0, Math.PI / 2);
  grassGeo.scale(1, 0.28, 1);
  const grass = new THREE.Mesh(grassGeo, mats.grass);
  grass.position.y = 0.36;
  island.add(new THREE.Mesh(sandGeo, mats.sand), grass);

  const palm = new THREE.Group();
  let tip = new THREE.Vector3(0, 0.45, 0);
  for (let i = 0; i < 6; i++) {
    const seg = new THREE.Mesh(new THREE.CylinderGeometry(0.07 - i * 0.006, 0.08 - i * 0.006, 0.36, 6), mats.trunk);
    const next = tip.clone().add(new THREE.Vector3(0.05 + i * 0.012, 0.33, 0));
    seg.position.copy(tip).lerp(next, 0.5);
    seg.lookAt(next);
    seg.rotateX(Math.PI / 2);
    palm.add(seg);
    tip = next;
  }
  const fronds = new THREE.Group();
  fronds.position.copy(tip);
  for (let i = 0; i < 7; i++) {
    const leaf = new THREE.Mesh(new THREE.ConeGeometry(0.16, 1.05, 4, 1, true), mats.palm);
    leaf.scale.set(1, 1, 0.25);
    leaf.position.y = 0.42;
    const arm = new THREE.Group();
    arm.rotation.set(1.05 + (i % 2) * 0.2, (i / 7) * Math.PI * 2, 0, 'YXZ');
    arm.add(leaf);
    fronds.add(arm);
  }
  palm.add(fronds);
  palm.position.set(-0.3, 0, 0.1);
  island.add(palm);
  scene.add(island);

  const boat = makeBoat(mats);
  scene.add(boat);

  const gulls = Array.from({ length: 3 }, (_, i) => {
    const g = makeGull(mats.gull);
    scene.add(g.gull);
    return { ...g, radius: 2.5 + i * 0.9, height: 3.6 + i * 0.5, speed: 0.35 + rand() * 0.2, phase: rand() * 6 };
  });

  const clouds = Array.from({ length: 5 }, () => {
    const cloud = new THREE.Group();
    const puffs = 3 + Math.floor(rand() * 3);
    for (let p = 0; p < puffs; p++) {
      const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5 + rand() * 0.4, 1), mats.cloud);
      puff.position.set((p - puffs / 2) * 0.6, rand() * 0.25, (rand() - 0.5) * 0.4);
      cloud.add(puff);
    }
    cloud.position.set((rand() - 0.5) * 30, 3.6 + rand() * 1.6, -10 - rand() * 6);
    scene.add(cloud);
    return { cloud, speed: 0.25 + rand() * 0.25, start: cloud.position.x };
  });

  let pitch = 0;
  let roll = 0;

  return {
    update(t) {
      for (let i = 0; i < waterPos.count; i++) {
        waterPos.setY(i, waveHeight(waterPos.getX(i), waterPos.getZ(i), t));
      }
      waterPos.needsUpdate = true;
      waterGeo.computeVertexNormals();

      // Boat circles the island, heading along its path and riding the swell.
      const angle = -t * 0.14;
      const x = Math.cos(angle) * BOAT_ORBIT;
      const z = Math.sin(angle) * BOAT_ORBIT;
      const heading = Math.atan2(Math.cos(angle), -Math.sin(angle)) - Math.PI / 2;
      const ahead = new THREE.Vector2(Math.sin(angle), -Math.cos(angle));
      const side = new THREE.Vector2(Math.cos(angle), Math.sin(angle));
      const h = waveHeight(x, z, t);
      const hBow = waveHeight(x + ahead.x * 0.9, z + ahead.y * 0.9, t);
      const hStern = waveHeight(x - ahead.x * 0.9, z - ahead.y * 0.9, t);
      const hPort = waveHeight(x + side.x * 0.35, z + side.y * 0.35, t);
      const hStar = waveHeight(x - side.x * 0.35, z - side.y * 0.35, t);
      pitch += (Math.atan2(hBow - hStern, 1.8) - pitch) * 0.1;
      roll += (Math.atan2(hPort - hStar, 0.7) - roll) * 0.1;
      boat.position.set(x, h - 0.06, z);
      boat.rotation.set(0, heading, 0);
      boat.rotateZ(pitch);
      boat.rotateX(roll);

      fronds.rotation.z = Math.sin(t * 1.3) * 0.05;

      gulls.forEach(g => {
        const a = t * g.speed + g.phase;
        g.gull.position.set(Math.cos(a) * g.radius, g.height + Math.sin(t * 0.9 + g.phase) * 0.2, Math.sin(a) * g.radius);
        g.gull.rotation.y = -a;
        const flap = 0.25 + Math.sin(t * 7 + g.phase) * 0.5; // V-shaped glide with flaps
        g.left.rotation.z = flap;
        g.right.rotation.z = -flap;
      });

      clouds.forEach(c => {
        c.cloud.position.x = ((c.start + t * c.speed + 20) % 40) - 20;
      });
    },
    setTheme(th) {
      mats.water.color.set(th.water);
      mats.sand.color.set(th.sand);
      mats.grass.color.set(th.grass);
      mats.palm.color.set(th.palm);
      mats.trunk.color.set(th.trunk);
      mats.hull.color.set(th.hull);
      mats.sail.color.set(th.sail);
      mats.cloud.color.set(th.cloud);
      hemi.groundColor.set(th.water);
      (scene.fog as THREE.Fog).color.set(th.horizon);
    },
  };
};

/** A sailboat circling a palm-tree island on a low-poly sea. */
export function Lagoon3D(props: ThreeSceneProps<LagoonTheme>) {
  return (
    <ThreeFrame
      {...props}
      defaultTheme={lagoonTheme}
      setup={setup}
      background={t => `linear-gradient(${t.skyTop}, ${t.horizon} 62%)`}
    />
  );
}
components/three/three-frame.tsx
'use client';

// Shared frame for every 3D scene: canvas sizing, render loop, pausing, theming and cleanup.
// Scenes provide a module-level `setup` function; this frame owns everything else.

import { useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react';
import * as THREE from 'three';
import styles from './three-frame.module.css';

/** Props every 3D scene accepts. `T` is the scene's theme (colour tokens). */
export type ThreeSceneProps<T> = {
  /** Override any of the scene's colours. Updates live without rebuilding the scene. */
  theme?: Partial<T>;
  /** Freeze the animation. Scenes also stop rendering while off-screen or in a hidden tab. */
  paused?: boolean;
  /** Animation speed multiplier (default 1). */
  speed?: number;
  className?: string;
  style?: CSSProperties;
  /** Content rendered on top of the canvas. */
  children?: ReactNode;
};

export type SceneContext<T> = {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  renderer: THREE.WebGLRenderer;
  theme: T;
};

export type SceneController<T> = {
  /** Advance the scene to `time` seconds (scaled by `speed`). */
  update: (time: number) => void;
  /** Apply new colours to existing materials and lights. */
  setTheme: (theme: T) => void;
};

/** Build the scene once. Must be defined at module level so its identity is stable. */
export type SceneSetup<T> = (ctx: SceneContext<T>) => SceneController<T>;

/** Deterministic PRNG so scenes look the same on every load. */
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;
  };
}

type ThreeFrameProps<T extends Record<string, string>> = ThreeSceneProps<T> & {
  defaultTheme: T;
  setup: SceneSetup<T>;
  /** CSS background behind the transparent canvas, e.g. a sky gradient. */
  background?: (theme: T) => string;
  /** Time shown as a still frame under prefers-reduced-motion. */
  stillTime?: number;
};

export function ThreeFrame<T extends Record<string, string>>({
  defaultTheme,
  setup,
  background,
  stillTime = 4,
  theme,
  paused = false,
  speed = 1,
  className,
  style,
  children,
}: ThreeFrameProps<T>) {
  const hostRef = useRef<HTMLDivElement>(null);
  const merged = useMemo(() => ({ ...defaultTheme, ...theme }) as T, [defaultTheme, theme]);

  // Live values read by the render loop without restarting it.
  const live = useRef({ theme: merged, paused, speed });
  live.current.speed = speed;
  const control = useRef<{ schedule: () => void; redraw: () => void; setTheme: (t: T) => void } | null>(null);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' });
    } catch {
      return; // No WebGL: leave the background and children visible.
    }
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.domElement.className = styles.canvas;
    host.prepend(renderer.domElement);

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 200);
    const controller = setup({ scene, camera, renderer, theme: live.current.theme });

    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
    let visible = true;
    let time = reduceMotion.matches ? stillTime : 0;
    let last = 0;
    let raf = 0;

    const draw = () => {
      controller.update(time);
      renderer.render(scene, camera);
    };
    const running = () => visible && !live.current.paused && !reduceMotion.matches && !document.hidden;
    const frame = (now: number) => {
      raf = 0;
      if (!running()) return;
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      time += delta * live.current.speed;
      draw();
      raf = requestAnimationFrame(frame);
    };
    const schedule = () => {
      if (reduceMotion.matches) time = stillTime;
      if (running() && !raf) {
        last = performance.now();
        raf = requestAnimationFrame(frame);
      } else if (!running()) {
        draw();
      }
    };

    const resize = () => {
      const { width, height } = host.getBoundingClientRect();
      if (!width || !height) return;
      renderer.setSize(width, height, false);
      camera.aspect = width / height;
      camera.updateProjectionMatrix();
      draw();
    };

    const resizeObserver = new ResizeObserver(resize);
    resizeObserver.observe(host);
    const intersectionObserver = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      schedule();
    });
    intersectionObserver.observe(host);
    document.addEventListener('visibilitychange', schedule);
    reduceMotion.addEventListener('change', schedule);

    control.current = { schedule, redraw: draw, setTheme: controller.setTheme };
    resize();
    schedule();

    return () => {
      cancelAnimationFrame(raf);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
      document.removeEventListener('visibilitychange', schedule);
      reduceMotion.removeEventListener('change', schedule);
      control.current = null;
      scene.traverse(object => {
        const mesh = object as THREE.Mesh;
        mesh.geometry?.dispose();
        const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
        materials.forEach(m => m.dispose());
      });
      renderer.dispose();
      renderer.domElement.remove();
    };
  }, [setup, stillTime]);

  useEffect(() => {
    live.current.theme = merged;
    control.current?.setTheme(merged);
    control.current?.redraw();
  }, [merged]);

  useEffect(() => {
    live.current.paused = paused;
    control.current?.schedule();
  }, [paused]);

  const classes = [styles.root, className].filter(Boolean).join(' ');
  return (
    <div ref={hostRef} className={classes} style={{ background: background?.(merged), ...style }}>
      {children && <div className={styles.overlay}>{children}</div>}
    </div>
  );
}
components/three/three-frame.module.css
.root {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  overflow: hidden;
}

.canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.overlay {
  position: absolute;
  inset: 0;
}