← Backgrounds
Waves
<WavesBackground /> A calm sea rolling along the bottom edge.
Your appContent sits on top of the background.
<WavesBackground>
{children}
</WavesBackground>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<WavesTheme> | — | Colour overrides. Keys listed under theme tokens. |
paused | boolean | false | Freezes the animation. |
className | string | — | Applied to the root element. Size it like any block element. |
style | CSSProperties | — | Merged into the root element style, after theme variables. |
children | ReactNode | — | Your content. The background paints behind it. |
| Theme key | CSS variable | Default |
|---|---|---|
skyTop | --sky-top | #d7ecf7 |
skyBottom | --sky-bottom | #f6fbfd |
wave1 | --wave-1 | #a9d4ea |
wave2 | --wave-2 | #7fbad9 |
wave3 | --wave-3 | #4f97bf |
wave4 | --wave-4 | #2f7aa6 |
foam | --foam | #ffffff |
Source files
Copy into components/backgrounds/. The shared files are needed once for all backgrounds.
// <WavesBackground>: a calm sea rolling along the bottom edge under an open sky: four wave layers
// scroll at different speeds, with foam glints on the crests. Good behind footers and heroes. Animated.
import { BackgroundFrame, type BackgroundProps } from './background';
import styles from './waves-background.module.css';
export const wavesTheme = {
skyTop: '#d7ecf7',
skyBottom: '#f6fbfd',
wave1: '#a9d4ea',
wave2: '#7fbad9',
wave3: '#4f97bf',
wave4: '#2f7aa6',
foam: '#ffffff',
};
export type WavesTheme = typeof wavesTheme;
/**
* A wave strip 800 units wide (two 400-unit periods) in a 100-unit-tall box, filled to the bottom.
* The strip is 200% of the layer's width, so sliding it by half loops seamlessly.
*/
const wave = (crest: number, amp: number, len: number, phase = 0) => {
let d = `M0 100 L0 ${crest}`;
for (let x = 0; x <= 800; x += 10) {
d += ` L${x} ${(crest - amp * Math.sin((x / len) * Math.PI * 2 + phase)).toFixed(1)}`;
}
return `${d} L800 100 Z`;
};
const LAYERS = [
{ height: 34, d: wave(30, 8, 200), fill: 'var(--wave-1)', time: 22, reverse: true },
{ height: 26, d: wave(30, 10, 400 / 3, 1), fill: 'var(--wave-2)', time: 16, reverse: false },
{ height: 19, d: wave(30, 12, 200, 2), fill: 'var(--wave-3)', time: 12, reverse: true },
{ height: 12, d: wave(30, 14, 400, 0.5), fill: 'var(--wave-4)', time: 9, reverse: false },
];
/** A calm sea rolling along the bottom edge under an open sky. */
export function WavesBackground(props: BackgroundProps<WavesTheme>) {
return (
<BackgroundFrame
{...props}
defaultTheme={wavesTheme}
layer={
<>
<div className={styles.sky} />
{LAYERS.map((l, i) => (
<div key={i} className={styles.band} style={{ height: `${l.height}%` }}>
<div className={styles.bob} style={{ animationDelay: `${-i * 0.9}s` }}>
<svg
className={`${styles.strip} ${l.reverse ? styles.reverse : ''}`}
style={{ animationDuration: `${l.time}s` }}
viewBox="0 0 800 100"
preserveAspectRatio="none"
>
<path d={l.d} fill={l.fill} />
</svg>
</div>
</div>
))}
<div className={styles.glints}>
{[12, 34, 58, 81].map((x, i) => (
<span key={x} className={styles.glint} style={{ left: `${x}%`, animationDelay: `${-i * 0.7}s` }} />
))}
</div>
</>
}
/>
);
}
.sky {
position: absolute;
inset: 0;
background: linear-gradient(var(--sky-top), var(--sky-bottom) 70%);
}
/* Each layer is a band anchored to the bottom; nearer bands are shorter, so they sit in front. */
.band {
position: absolute;
left: 0;
right: 0;
bottom: -4px;
}
.bob {
position: absolute;
inset: 0;
animation: bob 4.5s ease-in-out infinite alternate;
}
/* Twice the layer's width; sliding by half a strip is one full period. */
.strip {
display: block;
width: 200%;
height: 100%;
animation: roll linear infinite;
}
.reverse { animation-direction: reverse; }
.glints {
position: absolute;
left: 0;
right: 0;
bottom: 8%;
height: 4px;
}
.glint {
position: absolute;
width: 26px;
height: 2px;
border-radius: 1px;
background: var(--foam);
opacity: 0;
animation: glint 3s ease-in-out infinite;
}
@keyframes roll { to { transform: translateX(-50%); } }
@keyframes bob {
from { transform: translateY(-3px); }
to { transform: translateY(3px); }
}
@keyframes glint {
0%, 100% { opacity: 0; transform: scaleX(0.4); }
50% { opacity: 0.8; transform: scaleX(1); }
}
// Shared frame for every background: theming, layering and pausing.
// Backgrounds are pure server components: no client JavaScript.
import type { CSSProperties, ReactNode } from 'react';
import styles from './background.module.css';
export { styles as backgroundStyles };
/** Deterministic PRNG so particles render identically on server and client. */
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;
};
}
/** Round for stable, compact inline styles. */
export const r2 = (n: number) => Math.round(n * 100) / 100;
/** Props every background accepts. `T` is the background's theme (colour tokens). */
export type BackgroundProps<T> = {
/** Override any of the background's colours. */
theme?: Partial<T>;
/** Freeze animated backgrounds. Static backgrounds ignore it. */
paused?: boolean;
className?: string;
style?: CSSProperties;
/** Your content. The background paints behind it. */
children?: ReactNode;
};
// baseColor → --base-color, blob1 → --blob-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;
type BackgroundFrameProps<T extends Record<string, string>> = BackgroundProps<T> & {
defaultTheme: T;
/** The texture itself, rendered in an absolutely positioned layer behind the children. */
layer: ReactNode;
};
export function BackgroundFrame<T extends Record<string, string>>({
defaultTheme,
layer,
theme,
paused,
className,
style,
children,
}: BackgroundFrameProps<T>) {
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
if (value) vars[toVar(key)] = value;
}
const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' ');
return (
<div className={classes} style={{ ...vars, ...style } as CSSProperties}>
<div className={styles.layer} aria-hidden="true">
{layer}
</div>
{children}
</div>
);
}
/* The root establishes a stacking context so the layer sits behind children
without escaping behind the page. */
.root {
position: relative;
isolation: isolate;
}
.layer {
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
/* Lets particles move by the layer's own size: 100cqh is its full height, 100cqw its width. */
container-type: size;
}
/* A particle starts just above the layer at a random horizontal position. */
.particle {
position: absolute;
top: 0;
}
.paused,
.paused * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
.layer,
.layer * { animation: none !important; }
/* Without motion, particles rest at a scattered position instead of the top edge. */
.particle { top: var(--y, 0); }
}