bottega

three

Wave Plane

An R3F wireframe plane whose vertices displace along Z with Simplex-3D noise for a slow ocean-swell undulation. A single ShaderMaterial draws both the surface and a UV-derived grid (fract()/step()) that pulses brighter as waves crest beneath it — lit wire with no double-draw. A radial smoothstep vignette fades the edges into --background so the canvas bleeds seamlessly into the page. On pointer-enter the swell deepens and slows (amplitude +30%, frequency -15%) via a per-frame exponential ease-out. Composed over canvas-wrapper so it inherits WebGL feature-detect, lazy mount, and a reduced-motion / no-WebGL poster fallback. The scene is dynamic-imported ssr:false (R3F can't SSR), reads contract color tokens onto its shader uniforms, and disposes its geometry/material on unmount. Accepts DOM children overlaid over the canvas.

Ride the swell

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/wave-plane

Usage

Usagetsx
<WavePlane />

Props

PropTypeDefaultDescription
amplitudenumber0.35Peak Z-displacement of vertices in world units. Default 0.35.
frequencynumber0.8Spatial frequency of the noise field. Default 0.8.
speednumber0.4uTime multiplier — how fast the wave rolls. Default 0.4.
gridDivisionsnumber24Number of wireframe grid lines per axis. Default 24.
lineOpacitynumber0.55Base opacity of wireframe lines (0-1). Default 0.55.
colorModeenumforegroundWhich token family the grid lines pull from. Default "foreground".
interactivebooleantrueWhether pointer-enter triggers the amplitude/speed breath. Default true.
aspectstring16 / 9Aspect-ratio of the mount box. Default "16 / 9".
minHeightstringMin-height of the mount box, forwarded to canvas-wrapper (which reserves space before the scene mounts). Default "20rem". Set "0" when the parent already constrains height (e.g. a fixed-height grid cell) so the canvas tracks `aspect`/the cell instead of being floored at 20rem.
classNamestringClass forwarded to the outer canvas-wrapper div for sizing/positioning.
childrenReact.ReactNodeDOM children rendered over the canvas via absolute positioning.

Source

wave-plane.tsxtsx
"use client";

import type React from "react";
import dynamic from "next/dynamic";
import { CanvasWrapper } from "@/registry/three/canvas-wrapper/canvas-wrapper";
import { WavePlanePoster } from "./wave-plane-poster";
import type { ColorMode } from "./wave-plane-scene";

// R3F cannot SSR — load the <Canvas> scene client-side only. While it loads
// (and on every guarded path) the poster is what shows.
const WavePlaneScene = dynamic(() => import("./wave-plane-scene"), {
  ssr: false,
  loading: () => <WavePlanePoster />,
});

export interface WavePlaneProps {
  /** Peak Z-displacement of vertices in world units. Default 0.35. */
  amplitude?: number;
  /** Spatial frequency of the noise field. Default 0.8. */
  frequency?: number;
  /** uTime multiplier — how fast the wave rolls. Default 0.4. */
  speed?: number;
  /** Number of wireframe grid lines per axis. Default 24. */
  gridDivisions?: number;
  /** Base opacity of wireframe lines (0-1). Default 0.55. */
  lineOpacity?: number;
  /** Which token family the grid lines pull from. Default "foreground". */
  colorMode?: ColorMode;
  /** Whether pointer-enter triggers the amplitude/speed breath. Default true. */
  interactive?: boolean;
  /** Aspect-ratio of the mount box. Default "16 / 9". */
  aspect?: string;
  /**
   * Min-height of the mount box, forwarded to canvas-wrapper (which reserves
   * space before the scene mounts). Default "20rem". Set "0" when the parent
   * already constrains height (e.g. a fixed-height grid cell) so the canvas
   * tracks `aspect`/the cell instead of being floored at 20rem.
   */
  minHeight?: string;
  /** Class forwarded to the outer canvas-wrapper div for sizing/positioning. */
  className?: string;
  /** DOM children rendered over the canvas via absolute positioning. */
  children?: React.ReactNode;
}

/**
 * An R3F wireframe plane whose vertices displace along Z with Simplex-3D noise,
 * producing a slow ocean-swell undulation. A single ShaderMaterial draws both
 * the surface AND a UV-derived grid (fract()/step()) that pulses brighter as
 * waves crest beneath it — lit wire with no double-draw. A radial vignette fades
 * the edges into --background so the canvas bleeds seamlessly into the page.
 * On pointer-enter the swell deepens and slows (amplitude +30%, frequency -15%)
 * for quiet life. Composed over canvas-wrapper so it inherits the full 3D
 * contract: WebGL feature-detect, lazy mount, and a reduced-motion / no-WebGL
 * poster fallback. The scene is dynamic-imported ssr:false (R3F can't SSR) and
 * disposes its GPU resources on unmount.
 *
 * `children` are DOM nodes overlaid over the canvas — keep any interactive
 * elements here (in the DOM layer) so keyboard focus and tab order are
 * unaffected by the 3D scene.
 */
export function WavePlane({
  amplitude = 0.35,
  frequency = 0.8,
  speed = 0.4,
  gridDivisions = 24,
  lineOpacity = 0.55,
  colorMode = "foreground",
  interactive = true,
  aspect = "16 / 9",
  minHeight,
  className,
  children,
}: WavePlaneProps) {
  return (
    <div className="relative isolate bg-background outline-none">
      {/* The decorative canvas layer carries the img role + label so it reads
          as a single labelled background to AT. The interactive children
          overlay below is a SIBLING — outside this role — so its focusable
          contents are never flattened/hidden by role="img". */}
      <div role="img" aria-label="Animated wave surface background">
        <CanvasWrapper
          className={className}
          aspect={aspect}
          minHeight={minHeight}
          poster={<WavePlanePoster />}
        >
          <WavePlaneScene
            amplitude={amplitude}
            frequency={frequency}
            speed={speed}
            gridDivisions={gridDivisions}
            lineOpacity={lineOpacity}
            colorMode={colorMode}
            interactive={interactive}
            animated
          />
        </CanvasWrapper>
      </div>
      {children != null && (
        // DOM-over-canvas overlay: keyboard focus / tab order live here, never
        // inside the R3F canvas. Non-blocking by default; consumer opts in.
        <div className="pointer-events-none absolute inset-0 z-10 grid place-items-center [&_a]:pointer-events-auto [&_button]:pointer-events-auto">
          {children}
        </div>
      )}
    </div>
  );
}

export default WavePlane;

Dependencies

  • three
  • @react-three/fiber
  • @bottega/canvas-wrapper
  • @bottega/tokens