Web InnoventixFreeCode

Waves Background

Original · free

layered SVG wave background

byWeb InnoventixReact + Tailwind
bgxwavesbackgrounds
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/bgx-waves.json
bgx-waves.tsx
"use client";

import { useId, useMemo, useState, type CSSProperties } from "react";
import { useReducedMotion } from "motion/react";

/* ---------------------------------------------------------------------------
 * bgx-waves — layered SVG wave background
 * Four parallax wave bands drift at independent speeds to build depth behind
 * hero copy. Palette, motion speed and crest height are all live-controllable.
 * ------------------------------------------------------------------------- */

type PaletteKey = "aurora" | "lagoon" | "ember";

interface PaletteDef {
  label: string;
  swatch: [string, string];
  /** back → front fill colours (text-* so the SVG uses currentColor) */
  colors: [string, string, string, string];
}

interface WaveShape {
  ampBase: number;
  wavelength: number;
  phase: number;
  baseline: number;
  opacity: number;
  baseDuration: number;
  heightPct: number;
  direction: "left" | "right";
}

/* viewBox is two tiles wide so translateX(-50%) loops seamlessly.
   Every wavelength divides 1440 so each tile is perfectly periodic. */
const VIEW_W = 2880;
const VIEW_H = 320;

const LAYER_SHAPES: readonly WaveShape[] = [
  { ampBase: 26, wavelength: 720, phase: 0.0, baseline: 150, opacity: 0.4, baseDuration: 30, heightPct: 60, direction: "left" },
  { ampBase: 34, wavelength: 480, phase: 1.1, baseline: 186, opacity: 0.55, baseDuration: 23, heightPct: 68, direction: "right" },
  { ampBase: 42, wavelength: 360, phase: 2.4, baseline: 222, opacity: 0.72, baseDuration: 17, heightPct: 76, direction: "left" },
  { ampBase: 52, wavelength: 288, phase: 3.6, baseline: 256, opacity: 0.92, baseDuration: 12, heightPct: 84, direction: "right" },
];

const PALETTES: Record<PaletteKey, PaletteDef> = {
  aurora: {
    label: "Aurora",
    swatch: ["bg-indigo-400", "bg-violet-400"],
    colors: [
      "text-indigo-200 dark:text-indigo-950",
      "text-violet-300 dark:text-violet-900",
      "text-indigo-400 dark:text-violet-800",
      "text-violet-500 dark:text-indigo-700",
    ],
  },
  lagoon: {
    label: "Lagoon",
    swatch: ["bg-emerald-400", "bg-sky-400"],
    colors: [
      "text-emerald-200 dark:text-emerald-950",
      "text-sky-300 dark:text-sky-900",
      "text-emerald-400 dark:text-sky-800",
      "text-sky-500 dark:text-emerald-700",
    ],
  },
  ember: {
    label: "Ember",
    swatch: ["bg-rose-400", "bg-amber-400"],
    colors: [
      "text-rose-200 dark:text-rose-950",
      "text-amber-300 dark:text-amber-900",
      "text-rose-400 dark:text-amber-800",
      "text-amber-500 dark:text-rose-700",
    ],
  },
};

const PALETTE_KEYS: readonly PaletteKey[] = ["aurora", "lagoon", "ember"];

/** Build a filled wave path across both tiles. Purely numeric — no React. */
function buildWavePath(amp: number, wavelength: number, phase: number, baseline: number): string {
  const step = wavelength / 24;
  const y0 = baseline - amp * Math.sin(phase);
  let d = `M 0 ${VIEW_H} L 0 ${y0.toFixed(2)}`;
  for (let x = step; x <= VIEW_W; x += step) {
    const y = baseline - amp * Math.sin((x / wavelength) * Math.PI * 2 + phase);
    d += ` L ${x.toFixed(2)} ${y.toFixed(2)}`;
  }
  d += ` L ${VIEW_W} ${VIEW_H} Z`;
  return d;
}

const FOCUS_RING =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

export default function BgxWaves() {
  const uid = useId();
  const prefersReduced = !!useReducedMotion();

  const [palette, setPalette] = useState<PaletteKey>("aurora");
  const [speed, setSpeed] = useState(1.5);
  const [crest, setCrest] = useState(60);
  const [playing, setPlaying] = useState(true);

  const paths = useMemo(
    () =>
      LAYER_SHAPES.map((s) =>
        buildWavePath((s.ampBase * crest) / 100, s.wavelength, s.phase, s.baseline),
      ),
    [crest],
  );

  const active = PALETTES[palette];
  const speedId = `${uid}-speed`;
  const crestId = `${uid}-crest`;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 dark:bg-slate-950">
      <style>{`
        @keyframes bgxwaves-drift-left {
          from { transform: translate3d(0, 0, 0); }
          to   { transform: translate3d(-50%, 0, 0); }
        }
        @keyframes bgxwaves-drift-right {
          from { transform: translate3d(-50%, 0, 0); }
          to   { transform: translate3d(0, 0, 0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .bgxwaves-layer { animation: none !important; transform: none !important; }
        }
      `}</style>

      {/* Wave field */}
      <div className="pointer-events-none absolute inset-0" aria-hidden="true">
        {LAYER_SHAPES.map((s, i) => {
          const duration = s.baseDuration / speed;
          const style: CSSProperties = prefersReduced
            ? {}
            : {
                animationName: s.direction === "left" ? "bgxwaves-drift-left" : "bgxwaves-drift-right",
                animationDuration: `${duration.toFixed(2)}s`,
                animationTimingFunction: "linear",
                animationIterationCount: "infinite",
                animationPlayState: playing ? "running" : "paused",
                willChange: "transform",
              };
          return (
            <svg
              key={s.wavelength}
              className={`bgxwaves-layer absolute bottom-0 left-0 w-[200%] ${active.colors[i]}`}
              style={{ height: `${s.heightPct}%`, ...style }}
              viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
              preserveAspectRatio="none"
              focusable="false"
              aria-hidden="true"
            >
              <path d={paths[i]} fill="currentColor" fillOpacity={s.opacity} />
            </svg>
          );
        })}
      </div>

      {/* Legibility wash behind the copy */}
      <div
        className="pointer-events-none absolute inset-x-0 top-0 h-56 bg-gradient-to-b from-slate-50 via-slate-50/70 to-transparent dark:from-slate-950 dark:via-slate-950/70"
        aria-hidden="true"
      />

      {/* Content + controls */}
      <div className="relative z-10 mx-auto flex min-h-[600px] max-w-3xl flex-col items-start justify-start px-6 py-20 sm:px-8 sm:py-24">
        <span className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-wider text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-300">
          <span className={`h-1.5 w-1.5 rounded-full ${active.swatch[0]}`} />
          Background · SVG
        </span>

        <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
          Layered wave background
        </h2>
        <p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
          Four parallax wave bands drift at independent speeds to build a sense of depth behind your
          hero copy. Tune the palette, motion speed and crest height below and the field re-renders
          in real time.
        </p>

        {/* Control panel */}
        <div className="mt-8 w-full max-w-xl rounded-2xl border border-slate-200 bg-white/75 p-5 shadow-sm backdrop-blur-md sm:p-6 dark:border-slate-800 dark:bg-slate-900/75">
          <div className="flex flex-col gap-6">
            {/* Palette */}
            <fieldset>
              <legend className="mb-2 text-sm font-medium text-slate-700 dark:text-slate-200">
                Colour palette
              </legend>
              <div className="flex flex-wrap gap-2">
                {PALETTE_KEYS.map((key) => {
                  const p = PALETTES[key];
                  const selected = palette === key;
                  return (
                    <label key={key} className="relative cursor-pointer">
                      <input
                        type="radio"
                        name={`${uid}-palette`}
                        value={key}
                        checked={selected}
                        onChange={() => setPalette(key)}
                        className="peer sr-only"
                      />
                      <span
                        className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900 ${
                          selected
                            ? "border-indigo-400 bg-indigo-50 text-indigo-900 dark:border-indigo-500 dark:bg-indigo-950 dark:text-indigo-100"
                            : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600"
                        }`}
                      >
                        <span className="flex -space-x-1">
                          <span className={`h-3 w-3 rounded-full ring-1 ring-white dark:ring-slate-900 ${p.swatch[0]}`} />
                          <span className={`h-3 w-3 rounded-full ring-1 ring-white dark:ring-slate-900 ${p.swatch[1]}`} />
                        </span>
                        {p.label}
                      </span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            {/* Speed */}
            <div>
              <div className="mb-2 flex items-center justify-between">
                <label htmlFor={speedId} className="text-sm font-medium text-slate-700 dark:text-slate-200">
                  Motion speed
                </label>
                <span className="tabular-nums text-sm text-slate-500 dark:text-slate-400">
                  {speed.toFixed(1)}×
                </span>
              </div>
              <input
                id={speedId}
                type="range"
                min={0.5}
                max={3}
                step={0.5}
                value={speed}
                disabled={prefersReduced}
                aria-valuetext={`${speed.toFixed(1)} times speed`}
                onChange={(e) => setSpeed(Number(e.target.value))}
                className={`w-full cursor-pointer accent-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:accent-indigo-400 ${FOCUS_RING} rounded`}
              />
            </div>

            {/* Crest height */}
            <div>
              <div className="mb-2 flex items-center justify-between">
                <label htmlFor={crestId} className="text-sm font-medium text-slate-700 dark:text-slate-200">
                  Crest height
                </label>
                <span className="tabular-nums text-sm text-slate-500 dark:text-slate-400">
                  {crest}%
                </span>
              </div>
              <input
                id={crestId}
                type="range"
                min={0}
                max={100}
                step={5}
                value={crest}
                aria-valuetext={`${crest} percent crest height`}
                onChange={(e) => setCrest(Number(e.target.value))}
                className={`w-full cursor-pointer accent-indigo-500 dark:accent-indigo-400 ${FOCUS_RING} rounded`}
              />
            </div>

            {/* Play / pause + status */}
            <div className="flex flex-wrap items-center gap-3">
              <button
                type="button"
                onClick={() => setPlaying((v) => !v)}
                disabled={prefersReduced}
                aria-pressed={playing}
                aria-label={playing ? "Pause wave motion" : "Play wave motion"}
                className={`inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 ${FOCUS_RING}`}
              >
                {playing ? (
                  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
                    <rect x="3.5" y="2.5" width="3" height="11" rx="1" />
                    <rect x="9.5" y="2.5" width="3" height="11" rx="1" />
                  </svg>
                ) : (
                  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
                    <path d="M4 2.6v10.8a.8.8 0 0 0 1.22.68l8.4-5.4a.8.8 0 0 0 0-1.36l-8.4-5.4A.8.8 0 0 0 4 2.6Z" />
                  </svg>
                )}
                {playing ? "Pause" : "Play"}
              </button>

              <p className="text-sm text-slate-500 dark:text-slate-400" role="status">
                {prefersReduced
                  ? "Animation disabled by your system's reduced-motion setting."
                  : `${active.label} · ${playing ? "drifting" : "paused"}`}
              </p>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →