← Backgrounds

Waves

<WavesBackground /> A calm sea rolling along the bottom edge.

Animated · Source · Markdown

Your appContent sits on top of the background.
Theme
Usage
<WavesBackground>
  {children}
</WavesBackground>
Props and theme tokens
PropTypeDefaultDescription
themePartial<WavesTheme>—Colour overrides. Keys listed under theme tokens.
pausedbooleanfalseFreezes the animation.
classNamestring—Applied to the root element. Size it like any block element.
styleCSSProperties—Merged into the root element style, after theme variables.
childrenReactNode—Your content. The background paints behind it.
Theme keyCSS variableDefault
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.

components/backgrounds/waves-background.tsx
// <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>
        </>
      }
    />
  );
}
components/backgrounds/waves-background.module.css
.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); }
}
components/backgrounds/background.tsx
// 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>
  );
}
components/backgrounds/background.module.css
/* 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); }
}