Media
CinematicWaterBackground
Underwater backdrop of turbulent waves, drifting light shafts and rising bubbles.
npx bezel-add add cinematic-water-backgroundpackages/ui/src/media/CinematicWaterBackground.tsx · 309 lines
"use client";
import { useId, useMemo, type CSSProperties } from "react";
type Bubble = {
x: string;
s: string;
dur: string;
dly: string;
drift: string;
};
type CinematicWaterBackgroundProps = {
scene?: 1 | 2 | 3 | 4; // scroll-depth tier — lowers water opacity as scene rises
className?: string;
bubbleCount?: number;
};
function makeBubbles(count: number, seed: number): Bubble[] {
const out: Bubble[] = [];
let s = seed;
const rand = () => {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
for (let i = 0; i < count; i++) {
out.push({
x: `${(rand() * 100).toFixed(1)}%`,
s: `${(3.2 + rand() * 6).toFixed(1)}px`,
dur: `${(14 + rand() * 14).toFixed(1)}s`,
dly: `${(rand() * 20).toFixed(1)}s`,
drift: `${(rand() * 80 - 40).toFixed(0)}px`,
});
}
return out;
}
function WaveLayer({
uid,
suffix,
filterScale,
baseFrequency,
paths,
opacity,
className,
}: {
uid: string;
suffix: string;
filterScale: number;
baseFrequency: string;
paths: Array<{ d: string; w: number; o: number }>;
opacity: number;
className: string;
}) {
const gid = `${uid}-cg-${suffix}`;
const fid = `${uid}-wf-${suffix}`;
return (
<div className={className} style={{ opacity }}>
<svg className="cw__sv" viewBox="0 0 1800 1100" preserveAspectRatio="xMidYMid slice" aria-hidden>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#eafff4" stopOpacity="0" />
<stop offset="0.18" stopColor="#f2fff8" stopOpacity="0.95" />
<stop offset="0.5" stopColor="#ffffff" stopOpacity="1" />
<stop offset="0.82" stopColor="#e6fbef" stopOpacity="0.9" />
<stop offset="1" stopColor="#eafff4" stopOpacity="0" />
</linearGradient>
<filter id={fid} x="-12%" y="-12%" width="124%" height="124%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency={baseFrequency} numOctaves="3" seed="21" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale={filterScale} xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
<g filter={`url(#${fid})`}>
{paths.map((p, i) => (
<path
key={i}
d={p.d}
stroke={`url(#${gid})`}
strokeWidth={p.w}
fill="none"
opacity={p.o}
strokeLinecap="round"
/>
))}
</g>
</svg>
</div>
);
}
const WAVE_A: Array<{ d: string; w: number; o: number }> = [
{ d: "M100 220 Q400 280 700 240 Q1000 200 1300 250 Q1600 300 1900 240", w: 4.2, o: 0.4 },
{ d: "M-50 480 Q250 520 550 490 Q850 450 1150 500 Q1450 560 1750 510 Q2050 460 2350 500", w: 5.5, o: 0.32 },
{ d: "M80 720 Q380 760 680 730 Q980 690 1280 740 Q1580 800 1880 750", w: 3.8, o: 0.28 },
{ d: "M40 960 Q340 1000 640 970 Q940 930 1240 980 Q1540 1030 1840 990", w: 6.1, o: 0.35 },
{ d: "M200 340 Q500 300 800 350 Q1100 400 1400 360 Q1700 310 2000 350", w: 2.4, o: 0.45 },
{ d: "M-100 600 Q200 640 500 610 Q800 570 1100 620 Q1400 680 1700 630", w: 5.0, o: 0.22 },
];
const WAVE_B: Array<{ d: string; w: number; o: number }> = [
{ d: "M60 180 Q320 210 580 190 Q840 160 1100 200 Q1360 240 1620 200", w: 2.2, o: 0.22 },
{ d: "M-80 560 Q180 540 440 570 Q700 610 960 580 Q1220 540 1480 570", w: 1.6, o: 0.28 },
{ d: "M120 860 Q380 840 640 870 Q900 910 1160 880 Q1420 840 1680 870", w: 2.8, o: 0.2 },
{ d: "M40 400 Q300 430 560 410 Q820 380 1080 420 Q1340 460 1600 430", w: 1.4, o: 0.18 },
{ d: "M200 1040 Q460 1020 720 1050 Q980 1090 1240 1060 Q1500 1020 1760 1050", w: 2.0, o: 0.16 },
];
export function CinematicWaterBackground({
scene = 1,
className = "",
bubbleCount = 16,
}: CinematicWaterBackgroundProps) {
const uid = useId().replace(/:/g, "");
const bubbles = useMemo(() => makeBubbles(bubbleCount, 42), [bubbleCount]);
const sg = `${uid}-sg`;
const sf = `${uid}-sf`;
const sfw = `${uid}-sfw`;
return (
<>
<style>{`
.cw {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
isolation: isolate;
}
.cw__scene {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 1.5s ease;
}
.cw__scene--on { opacity: 1; }
.cw__s1 { background: linear-gradient(178deg, #1ea38d 0%, #0b8578 22%, #017069 48%, #4b3a8f 74%, #01524e 100%); }
.cw__s2 { background: linear-gradient(178deg, #12907f 0%, #04756e 26%, #4b3a8f 58%, #01514d 100%); }
.cw__s3 { background: linear-gradient(178deg, #067c71 0%, #00625d 30%, #014e4a 64%, #023c39 100%); }
.cw__s4 { background: linear-gradient(178deg, #036359 0%, #014b46 32%, #013431 66%, #012422 100%); }
.cw__water {
position: absolute;
inset: -14% -10%;
transition: opacity 1.2s ease;
}
.cw[data-d="1"] .cw__water { opacity: 1; }
.cw[data-d="2"] .cw__water { opacity: 0.8; }
.cw[data-d="3"] .cw__water { opacity: 0.58; }
.cw[data-d="4"] .cw__water { opacity: 0.34; }
.cw[data-d="1"] .cw__surface { opacity: 0.7; }
.cw[data-d="2"] .cw__surface,
.cw[data-d="3"] .cw__surface,
.cw[data-d="4"] .cw__surface { opacity: 0; }
.cw__layer {
position: absolute;
inset: 0;
mix-blend-mode: screen;
will-change: transform;
}
.cw__sv { width: 100%; height: 100%; display: block; }
.cw__a { animation: cw-drift-a 34s linear infinite; }
.cw__b { animation: cw-drift-b 23s linear infinite; }
.cw__shafts {
position: absolute;
inset: 0;
mix-blend-mode: screen;
animation: cw-shaft 19s ease-in-out infinite;
}
.cw__surface {
position: absolute;
inset: 0;
mix-blend-mode: screen;
transform-origin: 50% 0;
animation: cw-surface 11s ease-in-out infinite;
}
.cw__bub { position: absolute; inset: 0; overflow: hidden; }
.cw__bub span {
position: absolute;
bottom: -6%;
left: var(--x);
width: var(--s);
height: var(--s);
border-radius: 50%;
border: 1px solid rgba(230, 255, 242, 0.5);
background: radial-gradient(circle at 34% 30%, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0) 62%);
animation: cw-rise var(--dur) linear infinite;
animation-delay: var(--dly);
opacity: 0;
}
.cw__vig {
position: absolute;
inset: 0;
background:
radial-gradient(126% 86% at 50% 28%, transparent 38%, rgba(1, 50, 46, 0.2) 78%, rgba(1, 34, 32, 0.44) 100%),
linear-gradient(180deg, rgba(1, 38, 36, 0.3) 0%, transparent 18%, transparent 66%, rgba(1, 32, 30, 0.4) 100%);
}
@keyframes cw-drift-a {
0% { transform: translate3d(-3%, 0, 0) scale(1.06); }
50% { transform: translate3d(3%, -1.6%, 0) scale(1.13); }
100% { transform: translate3d(-3%, 0, 0) scale(1.06); }
}
@keyframes cw-drift-b {
0% { transform: translate3d(4%, 1%, 0) scale(1.1); }
50% { transform: translate3d(-4%, -1%, 0) scale(1.04); }
100% { transform: translate3d(4%, 1%, 0) scale(1.1); }
}
@keyframes cw-shaft {
0% { transform: translate3d(-2%, 0, 0) skewX(0deg); opacity: 0.34; }
50% { transform: translate3d(2%, 0, 0) skewX(2.4deg); opacity: 0.6; }
100% { transform: translate3d(-2%, 0, 0) skewX(0deg); opacity: 0.34; }
}
@keyframes cw-surface {
0% { transform: translateY(0) scaleY(1); opacity: 0.55; }
50% { transform: translateY(-1.4%) scaleY(1.14); opacity: 0.82; }
100% { transform: translateY(0) scaleY(1); opacity: 0.55; }
}
@keyframes cw-rise {
0% { transform: translate3d(0, 0, 0); opacity: 0; }
12% { opacity: 0.7; }
88% { opacity: 0.5; }
100% { transform: translate3d(var(--drift), -116vh, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cw__a, .cw__b, .cw__shafts, .cw__surface, .cw__bub span { animation: none !important; }
.cw__bub { display: none; }
}
`}</style>
<div className={`cw ${className}`.trim()} data-d={String(scene)} aria-hidden>
{[1, 2, 3, 4].map((n) => (
<div
key={n}
className={`cw__scene cw__s${n}${scene === n ? " cw__scene--on" : ""}`}
/>
))}
<div className="cw__water">
<WaveLayer
uid={uid}
suffix="a"
filterScale={72}
baseFrequency="0.0022 0.019"
paths={WAVE_A}
opacity={0.8}
className="cw__layer cw__a"
/>
<WaveLayer
uid={uid}
suffix="b"
filterScale={46}
baseFrequency="0.005 0.031"
paths={WAVE_B}
opacity={0.54}
className="cw__layer cw__b"
/>
<div className="cw__shafts">
<svg className="cw__sv" viewBox="0 0 1800 1100" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id={sg} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#f4fff9" stopOpacity="0.85" />
<stop offset="0.38" stopColor="#dcf6e7" stopOpacity="0.28" />
<stop offset="1" stopColor="#d4f2e2" stopOpacity="0" />
</linearGradient>
</defs>
<polygon points="95,-80 152,-80 517,1180 439,1180" fill={`url(#${sg})`} opacity="0.26" />
<polygon points="433,-80 491,-80 885,1180 807,1180" fill={`url(#${sg})`} opacity="0.41" />
<polygon points="751,-80 919,-80 1180,1180 953,1180" fill={`url(#${sg})`} opacity="0.31" />
<polygon points="1048,-80 1135,-80 1400,1180 1282,1180" fill={`url(#${sg})`} opacity="0.28" />
<polygon points="1366,-80 1520,-80 1898,1180 1690,1180" fill={`url(#${sg})`} opacity="0.28" />
</svg>
</div>
<div className="cw__surface">
<svg className="cw__sv" viewBox="0 0 1800 1100" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id={sf} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#ffffff" stopOpacity="0.55" />
<stop offset="0.55" stopColor="#e8fff2" stopOpacity="0.16" />
<stop offset="1" stopColor="#e8fff2" stopOpacity="0" />
</linearGradient>
<filter id={sfw} x="-10%" y="-40%" width="120%" height="180%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.004 0.05" numOctaves="2" seed="9" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="30" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
<g filter={`url(#${sfw})`}>
<rect x="-100" y="-40" width="2000" height="150" fill={`url(#${sf})`} />
<rect x="-100" y="60" width="2000" height="60" fill={`url(#${sf})`} opacity="0.5" />
</g>
</svg>
</div>
<div className="cw__bub">
{bubbles.map((b, i) => (
<span
key={i}
style={
{
"--x": b.x,
"--s": b.s,
"--dur": b.dur,
"--dly": b.dly,
"--drift": b.drift,
} as CSSProperties
}
/>
))}
</div>
</div>
<div className="cw__vig" />
</div>
</>
);
}