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.
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/wave-planeUsage
<WavePlane />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| amplitude | number | 0.35 | Peak Z-displacement of vertices in world units. Default 0.35. |
| frequency | number | 0.8 | Spatial frequency of the noise field. Default 0.8. |
| speed | number | 0.4 | uTime multiplier — how fast the wave rolls. Default 0.4. |
| gridDivisions | number | 24 | Number of wireframe grid lines per axis. Default 24. |
| lineOpacity | number | 0.55 | Base opacity of wireframe lines (0-1). Default 0.55. |
| colorMode | enum | foreground | Which token family the grid lines pull from. Default "foreground". |
| interactive | boolean | true | Whether pointer-enter triggers the amplitude/speed breath. Default true. |
| aspect | string | 16 / 9 | Aspect-ratio of the mount box. Default "16 / 9". |
| minHeight | 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. |
| className | string | — | Class forwarded to the outer canvas-wrapper div for sizing/positioning. |
| children | React.ReactNode | — | DOM children rendered over the canvas via absolute positioning. |
Source
"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