About
The sea from eye level, in one fragment shader. Five travelling waves, all running within a few degrees of the view axis and stretched sideways, make a long swell instead of a lattice; the shader finds where each ray meets the moving surface in four steps, lights the faces with the sun and the sky they reflect, and breaks the sun's reflection into slivers with fine ripples that fade with distance. Fog pulls the far water into the horizon's own colour. Seven looks are palettes and a handful of numbers, so a recolour or a new mood costs a few lines. Ported in spirit from the Trident site's dusk sea (the studio's own client), rewritten for the shared stage.
Use it when
- A hotel, resort, beach club, charter or cruise page wants its hero to feel like the water it sells.
- An insurer, adviser or law practice wants calm weather (the harbor look) or a storm to weather.
- A wellness or yoga page wants a pastel dawn; a nightlife page wants moonlight.
Skip it when
- The brand has nothing to do with water or the outdoors; use Spectrum Mesh or Liquid Ink.
- The page already has a 3D centrepiece or another animated background.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/ocean - 3Use it:
import { Ocean } from "@/components/vibebox/ocean"; <Ocean preset="dusk" className="min-h-[560px] rounded-xl"> <div className="p-12 text-white"> <h1 className="text-4xl font-semibold">A slow weekend, a short drive from Manila.</h1> </div> </Ocean>
Code
3 filescomponents/vibebox/ocean/index.tsx
/**
* VibeBox Motion · Ocean
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { Ocean, type OceanProps, type OceanColor, type OceanValue } from "./ocean.client";
export { PRESET_NAMES as OCEAN_PRESETS, presets as oceanPresets, type OceanPreset } from "./ocean.shader";components/vibebox/ocean/ocean.client.tsx
/**
* VibeBox Motion · Ocean
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import type { ReactNode } from "react";
import { BackdropCanvas, type PosterSpec } from "@/lib/vibebox/backdrop-canvas";
import { oceanPiece, presets, type OceanPreset } from "./ocean.shader";
export type OceanColor = "zenith" | "horizon" | "sun" | "glow" | "deep" | "shallow" | "foam";
export type OceanValue = "sunX" | "sunY" | "waves" | "chop" | "haze" | "stars" | "clouds" | "storm" | "foamAmount" | "horizonY";
export type OceanProps = {
/** What sits on the scene: a heading, a call to action, a whole section. */
children?: ReactNode;
/** A named look: `dusk`, `harbor`, `lagoon`, `night`, `storm`, `fog`, `dawn`. */
preset?: OceanPreset;
/** Overrides single palette colours with any CSS colour. */
colors?: Partial<Record<OceanColor, string>>;
/** Multiplies the preset's speed (1 keeps it, 0 holds the scene still). */
speed?: number;
/** Multiplies the preset's brightness. */
intensity?: number;
/** The pointer and touch move the scene. */
interactive?: boolean;
/** Accessible name; leave it out when the scene is only a background. */
label?: string;
/** Hold one still frame until this is false (for a gallery card). */
still?: boolean;
className?: string;
} & Partial<Record<OceanValue, number>>;
const POSTER: PosterSpec = { type: "horizon", top: "zenith", horizon: "horizon", groundTop: "deep", groundBottom: "shallow", at: 45, glow: "sun", glowAt: [62, 43] };
export function Ocean({ children, preset, colors, speed, intensity, interactive = true, label, still, className, sunX, sunY, waves, chop, haze, stars, clouds, storm, foamAmount, horizonY }: OceanProps) {
return (
<BackdropCanvas
slug="ocean"
piece={oceanPiece}
presets={presets}
fallback="dusk"
preset={preset}
colors={colors}
values={{ sunX, sunY, waves, chop, haze, stars, clouds, storm, foamAmount, horizonY }}
speed={speed}
intensity={intensity}
poster={POSTER}
scale={0.5}
stillTime={3.2}
still={still}
interactive={interactive}
label={label}
className={className}
>
{children}
</BackdropCanvas>
);
}components/vibebox/ocean/ocean.shader.ts
/**
* VibeBox Motion · Ocean
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { compose, createScenePiece, type ScenePreset } from "@/lib/vibebox/engine/backdrops";
/*
* The sea from a low viewpoint: a swell of directional waves ray-marched against a plane, lit by
* a sun or moon that sets the sky, the glitter path and the colour of the faces turned toward it.
* The pointer drops rings of ripples into the water. Everything an author tunes is a colour
* (`zenith`, `horizon`, `sun`, `glow`, `deep`, `shallow`, `foam`) or a number (`sunX`, `sunY`,
* `waves`, `chop`, `haze`, `stars`, `clouds`, `storm`, `foam`, `horizonY`, `speed`, `intensity`).
*/
const SCENE = /* glsl */ `
uniform vec3 uZenith;
uniform vec3 uHorizon;
uniform vec3 uSun;
uniform vec3 uGlow;
uniform vec3 uDeep;
uniform vec3 uShallow;
uniform vec3 uFoam;
uniform float uSunX;
uniform float uSunY;
uniform float uWaves;
uniform float uChop;
uniform float uHaze;
uniform float uStars;
uniform float uClouds;
uniform float uStorm;
uniform float uFoamAmount;
uniform float uHorizonY;
uniform float uSpeed;
uniform float uIntensity;
const float FOCAL = 1.1;
const float CAM_Y = 1.35;
float T;
vec2 ripple; // where the pointer touches the water, on the water plane
/* ---- the sky ---- */
vec3 sunDirection() { return normalize(vec3(uSunX * 0.85, 0.012 + uSunY * 0.42, -1.0)); }
float starsAt(vec3 rd) {
vec2 g = vec2(atan(rd.x, -rd.z) * 70.0, rd.y * 110.0);
ivec2 c = ivec2(floor(g));
vec2 f = fract(g);
vec2 o = h22(c) * 0.6 + 0.2;
float r = h2(c + ivec2(5, 9));
float d = length(f - o);
float tw = 0.6 + 0.4 * sin(T * (1.0 + r * 3.0) + r * 40.0);
return smoothstep(0.075 + r * 0.04, 0.0, d) * step(0.86, r) * tw * smoothstep(0.02, 0.16, rd.y);
}
vec3 sky(vec3 rd, float flash) {
vec3 L = sunDirection();
float y = max(rd.y, 0.0);
float sd = max(dot(rd, L), 0.0);
vec3 zen = lin(uZenith), hor = lin(uHorizon), sun = lin(uSun), glow = lin(uGlow);
// The gradient is mixed in display space, where designers expect colours to blend.
vec3 col = lin(mix(uHorizon, uZenith, smoothstep(0.0, 0.62, pow(y, 0.7))));
// A warm band hugging the horizon, brightest toward the sun.
col += glow * exp(-y * 20.0) * (0.06 + 1.1 * pow(sd, 5.0));
col += sun * (pow(sd, 8.0) * 0.07 + pow(sd, 60.0) * 0.26 + pow(sd, 500.0) * 0.9);
col += sun * 3.2 * smoothstep(0.99935, 0.99985, sd);
// Long, thin cloud bands lit from below by the low sun.
if (uClouds > 0.001) {
vec2 cp = rd.xz / (rd.y + 0.22) * vec2(0.7, 2.6) + vec2(T * 0.012, 0.0);
float c = smoothstep(0.42, 0.85, fbm3(cp * 1.4 + 3.0)) * smoothstep(0.0, 0.14, y) * (1.0 - smoothstep(0.55, 1.0, y)) * uClouds;
vec3 under = mix(hor * 0.8, glow + sun * 0.5, pow(sd, 1.5));
col = mix(col, under + zen * 0.25, c * 0.75);
}
if (uStars > 0.001) col += vec3(0.8, 0.88, 1.0) * starsAt(rd) * uStars * 1.4;
col += vec3(0.62, 0.7, 0.9) * flash * smoothstep(0.0, 0.5, y + 0.1);
return col;
}
/* ---- the water: a sum of travelling waves, sharpened toward the crest ---- */
float height(vec2 p, float dist) {
float h = 0.0;
// Crests meander a little along their length (one axis only, so they never close into rings).
p.x += 0.5 * sin(p.y * 0.23 + T * 0.18) + 0.2 * sin(p.y * 0.61 - T * 0.27);
// Crests run across the view: every wave travels within a few degrees of the view axis, and
// the field is stretched sideways, so the sea reads as long swell, not a lattice.
vec2 s = p * vec2(0.55, 1.0);
for (int i = 0; i < 5; i++) {
float fi = float(i);
float ang = (fi - 2.0) * 0.2 + 0.12 * sin(fi * 3.1 + 0.7);
vec2 d = vec2(sin(ang), cos(ang));
float k = 0.46 * pow(1.72, fi);
float amp = uWaves * 0.34 * pow(0.5, fi) * (i >= 3 ? uChop : 1.0);
// Fine waves fade with distance, so the far water stays calm instead of shimmering.
amp *= 1.0 / (1.0 + dist * k * 0.02);
float ph = dot(d, s) * k + T * sqrt(9.8 * k) * 0.4 + fi * 1.9;
float w = 0.5 + 0.5 * sin(ph);
h += amp * (pow(w, 1.0 + 0.55 * uChop) - 0.4);
}
// The pointer's rings.
float r = length(p - ripple);
h += uHover * 0.05 * sin(r * 11.0 - T * 6.0) * exp(-r * 0.85);
return h;
}
vec3 waterNormal(vec2 p, float dist) {
float e = 0.04 + dist * 0.0035;
float hc = height(p, dist);
float hx = height(p + vec2(e, 0.0), dist);
float hz = height(p + vec2(0.0, e), dist);
vec3 n = vec3(hc - hx, e * 1.35, hc - hz);
// Fine ripples ride the swell and fade with distance.
vec2 q = p * vec2(1.3, 3.6) + vec2(T * 0.35, -T * 0.5);
vec2 micro = vec2(gnoise(q), gnoise(q + 9.3));
n.xz += micro * (0.06 + 0.1 * uChop) * e * 5.0 / (1.0 + dist * 0.05);
// The far swell lies flatter.
n.xz *= 1.0 / (1.0 + dist * 0.01);
return normalize(n);
}
void main() {
vec2 uv = centred(gl_FragCoord.xy);
T = uTime * uSpeed;
float aspect = uResolution.x / uResolution.y;
vec3 ro = vec3(0.0, CAM_Y, 0.0);
vec3 rd = normalize(vec3(uv.x, uv.y - uHorizonY, -FOCAL));
// Where the pointer meets the water.
vec2 pu = vec2((uPointer.x - 0.5) * aspect, uPointer.y - 0.5);
vec3 pr = normalize(vec3(pu.x, pu.y - uHorizonY, -FOCAL));
ripple = pr.y < -0.01 ? (ro + pr * (CAM_Y / -pr.y)).xz : vec2(0.0, -400.0);
// Lightning: a flicker that fades quickly, only in a storm.
float flash = 0.0;
if (uStorm > 0.001) {
float b = T * 0.55;
float k = floor(b);
float on = step(0.72, h1(int(k) + 17));
float f = fract(b);
flash = uStorm * on * (exp(-f * 9.0) * 0.9 + exp(-abs(f - 0.14) * 40.0) * 0.5);
}
vec3 col;
if (rd.y >= -0.0005) {
col = sky(rd, flash);
} else {
float dist = min(CAM_Y / -rd.y, 700.0);
for (int i = 0; i < 4; i++) {
vec3 p = ro + rd * dist;
float h = height(p.xz, dist);
dist = clamp(dist + (p.y - h) / -rd.y * 0.75, 0.2, 700.0);
}
vec3 p = ro + rd * dist;
vec3 n = waterNormal(p.xz, dist);
vec3 v = -rd;
vec3 L = sunDirection();
float fres = 0.025 + 0.975 * pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 5.0);
vec3 r = reflect(rd, n);
r.y = abs(r.y) + 0.001;
vec3 refl = sky(r, flash);
// The body of the water: deep far away and where the face looks down, brighter on faces
// turned toward the light (a little light passes through the crests).
float lit = clamp(0.5 + 2.4 * dot(n.xz, normalize(L.xz + vec2(0.0001, -1.0))), 0.0, 1.0);
// Height as a fraction of the swell, so a rough sea keeps its dark troughs.
float crest = clamp(height(p.xz, dist) / max(uWaves * 0.55, 0.12) * 0.5 + 0.5, 0.0, 1.0);
float body_t = clamp(0.1 + 0.5 * lit + 0.45 * crest * crest, 0.0, 1.0);
vec3 body = mix(lin(uDeep), lin(uShallow), body_t);
// Less light on the water when the sun is down.
body *= 0.5 + 0.9 * smoothstep(-0.02, 0.3, L.y);
vec3 sun = lin(uSun);
col = mix(body, refl, fres);
// The glitter path: the sun's reflection broken into slivers by the facets.
float sr = clamp(dot(r, L), 0.0, 1.0);
float glint = smoothstep(0.1, 0.62, gnoise(p.xz * vec2(7.0, 15.0) + vec2(T * 0.7, -T * 0.6)) + 0.3 * gnoise(p.xz * vec2(19.0, 31.0) - T * 0.9));
col += sun * (pow(sr, 900.0) * 9.0 * (0.25 + glint) + pow(sr, 90.0) * 0.5 * (0.4 + 0.8 * glint) + pow(sr, 12.0) * 0.06);
// Foam: thin streaks along the steepest crests, for rougher seas.
if (uFoamAmount > 0.001) {
// Thin streaks along the crests, in the middle distance only: close up the water is clear,
// far off the swell is too fine to see foam.
float streak = 0.5 + 0.5 * gnoise(p.xz * vec2(0.9, 5.5) + vec2(T * 0.3, T * 0.55));
float fm = smoothstep(0.74, 0.96, crest * 0.8 + streak * 0.3);
fm *= smoothstep(8.0, 22.0, dist) * (1.0 - smoothstep(40.0, 130.0, dist));
col = mix(col, lin(uFoam) * (0.3 + 0.7 * (flash + 0.35 + L.y * 1.5)), fm * uFoamAmount * 0.65);
}
// Distance fog into the horizon's own colour.
vec3 fogDir = normalize(vec3(rd.x, 0.012, rd.z));
float fog = 1.0 - exp(-dist * (0.0007 + uHaze * 0.0042));
col = mix(col, sky(fogDir, flash), clamp(fog, 0.0, 1.0));
}
col *= uIntensity;
// A soft vignette keeps the eye in the middle of the frame.
col *= 1.0 - 0.28 * smoothstep(0.55, 1.15, length(uv * vec2(0.9, 1.1)));
fragColor = vec4(finish(col, gl_FragCoord.xy), 1.0);
}
`;
export const fragment = compose(SCENE);
export const PRESET_NAMES = ["dusk", "harbor", "lagoon", "night", "storm", "fog", "dawn"] as const;
export type OceanPreset = (typeof PRESET_NAMES)[number];
export const presets: Record<OceanPreset, ScenePreset> = {
dusk: {
colors: { zenith: "#1a1c4d", horizon: "#f2825f", sun: "#ffd7a1", glow: "#ff6a4a", deep: "#0a1033", shallow: "#37608a", foam: "#ffe9d6" },
values: { sunX: 0.22, sunY: 0.06, waves: 0.95, chop: 0.8, haze: 0.45, stars: 0.12, clouds: 0.55, storm: 0, foamAmount: 0, horizonY: 0.1, speed: 0.7, intensity: 1 },
},
harbor: {
colors: { zenith: "#050a1e", horizon: "#16284a", sun: "#ffc569", glow: "#e5893a", deep: "#02060f", shallow: "#10304a", foam: "#f6d9a5" },
values: { sunX: 0.3, sunY: 0.03, waves: 0.75, chop: 0.7, haze: 0.6, stars: 0.2, clouds: 0.3, storm: 0, foamAmount: 0, horizonY: 0.1, speed: 0.6, intensity: 1.05 },
},
lagoon: {
colors: { zenith: "#2f86dc", horizon: "#c4eef2", sun: "#fff7d8", glow: "#dffaff", deep: "#0a6d8a", shallow: "#5ed8cf", foam: "#ffffff" },
values: { sunX: -0.35, sunY: 0.62, waves: 0.55, chop: 1.1, haze: 0.28, stars: 0, clouds: 0.4, storm: 0, foamAmount: 0.15, horizonY: 0.12, speed: 0.7, intensity: 1.05 },
},
night: {
colors: { zenith: "#02040d", horizon: "#13213f", sun: "#d4e3ff", glow: "#35538f", deep: "#01030a", shallow: "#0c2240", foam: "#cfe0ff" },
values: { sunX: -0.3, sunY: 0.3, waves: 0.65, chop: 0.7, haze: 0.5, stars: 1, clouds: 0.18, storm: 0, foamAmount: 0, horizonY: 0.12, speed: 0.55, intensity: 1.05 },
},
storm: {
colors: { zenith: "#080b11", horizon: "#2c3542", sun: "#a3b3ca", glow: "#3c485b", deep: "#04070c", shallow: "#1c2c37", foam: "#d3dde6" },
values: { sunX: 0.1, sunY: 0.2, waves: 1.7, chop: 1.4, haze: 0.8, stars: 0, clouds: 1, storm: 1, foamAmount: 0.9, horizonY: 0.08, speed: 1.1, intensity: 1 },
},
fog: {
colors: { zenith: "#bcc8d3", horizon: "#e9eef1", sun: "#ffffff", glow: "#f5f7f8", deep: "#6a8394", shallow: "#9fb4c0", foam: "#ffffff" },
values: { sunX: 0.1, sunY: 0.22, waves: 0.4, chop: 0.4, haze: 1, stars: 0, clouds: 0.35, storm: 0, foamAmount: 0, horizonY: 0.1, speed: 0.45, intensity: 1 },
},
dawn: {
colors: { zenith: "#7a8bd4", horizon: "#ffd3c6", sun: "#fff1da", glow: "#ffb3b9", deep: "#2b4b7c", shallow: "#92b4d8", foam: "#fff4ec" },
values: { sunX: -0.2, sunY: 0.08, waves: 0.5, chop: 0.55, haze: 0.5, stars: 0, clouds: 0.6, storm: 0, foamAmount: 0, horizonY: 0.1, speed: 0.5, intensity: 1.04 },
},
};
export const oceanPiece = createScenePiece("ocean", fragment);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | What sits on the scene: a heading, a call to action, a whole section. |
| preset | "dusk" | "harbor" | "lagoon" | "night" | "storm" | "fog" | "dawn" | "dusk" | The look: dusk, harbor, lagoon, night, storm, fog, dawn. |
| colors | { zenith?: string; horizon?: string; sun?: string; glow?: string; deep?: string; shallow?: string; foam?: string } | — | Overrides single palette colours with any CSS colour: zenith (the sky overhead), horizon (the sky at the horizon), sun (the sun or moon and its glitter), glow (the warm band along the horizon), deep (the water far off and in the troughs), shallow (the water on lit faces and crests), foam (foam and crest light). |
| speed | number | 1 | Multiplies the preset's speed; 0 holds the scene still. |
| intensity | number | 1 | Multiplies the preset's brightness, 0.5 to 1.4. |
| sunX | number | 0.22 | Where the sun sits across the frame (-1 to 1; each look sets its own). |
| sunY | number | 0.06 | How high the sun stands; 0 sits on the horizon (0 to 1; each look sets its own). |
| waves | number | 0.95 | The height of the swell (0.2 to 2; each look sets its own). |
| chop | number | 0.8 | How much fine ripple rides the swell (0 to 1.6; each look sets its own). |
| haze | number | 0.45 | Distance fog into the horizon (0 to 1; each look sets its own). |
| stars | number | 0.12 | How many stars show in the sky (0 to 1; each look sets its own). |
| clouds | number | 0.55 | Thin cloud bands in the sky (0 to 1; each look sets its own). |
| storm | number | 0 | Lightning flashes (0 to 1; each look sets its own). |
| foamAmount | number | 0 | Foam streaks on the crests (0 to 1; each look sets its own). |
| horizonY | number | 0.1 | Where the horizon falls in the frame; 0 is the middle (-0.2 to 0.3; each look sets its own). |
| interactive | boolean | true | The pointer and touch drop rings of ripples into the water. |
| label | string | — | Accessible name; leave it out when the scene is only a background. |
| still | boolean | false | Hold one composed frame until false (a gallery card that plays on hover). |
| className | string | — | Merged with cn(); give it a size and a radius. |
Accessibility and motion
- Keyboard
- Not focusable; content on it keeps its own focus order.
- Screen readers
- Decorative by default; with
labelit is an image with that name. - Touch
- A finger drops ripples like the cursor; scrolling is never blocked.
- Reduced motion
- One still frame of the sea; no swell, glitter or ripples.
Edge cases and limits
- The sun's reflection is bright: set text away from the glitter path or on a card.
- Storm is the heaviest look; on low-end phones the stage settles to a still frame after a sustained slow stretch.
- A high sun (lagoon) puts the glitter in the near water, so there is no clean calm area behind the heading; use a card or darken the scene.
Related

Deep Sea
FreeUnderwater: light shafts slanting down, caustics near the top and flecks of marine snow.
Backgrounds · WebGL2

Aurora
FreeCurtains of northern light over a night sky and a mountain ridge, drawn toward the pointer.
Backgrounds · WebGL2

Bokeh
FreeOut-of-focus lights in three depths, brighter at the rim and drifting with the pointer.
Backgrounds · WebGL2