bottega

backgrounds

Wave Lines

Layered flowing sine-wave background layer. Several SVG sine-wave paths (stroked in --primary/--border/--foreground at varied alpha and stroke-width) scroll horizontally in a seamless translateX loop, creating a calm contour-lines ambience. Wave geometry is computed deterministically from props — SSR-safe, no Math.random(). Animation is mount-gated for hydration safety. Decorative (aria-hidden, pointer-events:none, z-0). Place sibling content at z-10.

wave-lines

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/wave-lines

Usage

Usagetsx
<WaveLines />

Props

PropTypeDefaultDescription
lineCountnumber5Number of wave lines stacked vertically. Default 5. Max clamped to 12.
amplitudenumber14Wave amplitude in viewBox units (0–40). Default 14.
speednumber12Seconds per full horizontal scroll cycle. Default 12. Lower = faster.

Source

wave-lines.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./wave-lines.module.css";

export interface WaveLinesProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Number of wave lines stacked vertically. Default 5. Max clamped to 12. */
  lineCount?: number;
  /** Wave amplitude in viewBox units (0–40). Default 14. */
  amplitude?: number;
  /** Seconds per full horizontal scroll cycle. Default 12. Lower = faster. */
  speed?: number;
}

// ---------------------------------------------------------------------------
// Wave geometry — deterministic, no Math.random() → SSR-safe.
// ---------------------------------------------------------------------------

/** Compute per-line wave parameters from its index (no randomness). */
function waveConfig(i: number, total: number, amplitude: number) {
  const spread = total > 1 ? total - 1 : 1;
  // Distribute wave center-lines evenly from 10% to 90% of the viewBox height.
  const centerY = 10 + (80 * i) / spread;
  // Vary amplitude with a fixed sine of the index so lines feel organic, not uniform.
  const amp = amplitude * (0.5 + 0.5 * Math.abs(Math.sin(i * 1.3)));
  // Cycle through 3 horizontal frequencies: 1, 1.5, 2 periods per tile.
  const freq = 1 + (i % 3) * 0.5;
  // Stagger starting phases so lines don't peak in unison.
  const phase = i * 0.9;
  return { centerY, amp, freq, phase };
}

/**
 * Build the SVG `d` attribute for ONE wave spanning TWO tile-widths (viewBox 0..200).
 *
 * The SVG element is styled at `width:200%`; animating `translateX(-50%)` shifts it
 * left by exactly one tile, revealing the second — which is identical — for a
 * seamless horizontal loop with a single CSS keyframe.
 */
function buildWavePath(i: number, total: number, amplitude: number): string {
  const { centerY, amp, freq, phase } = waveConfig(i, total, amplitude);
  const TILE = 100; // viewBox units per tile (two tiles → viewBox width 200)
  const STEPS = 80; // sample points per tile (160 total)
  const pts: string[] = [];
  for (let s = 0; s <= STEPS * 2; s++) {
    const x = (s / STEPS) * TILE; // 0..200
    const y =
      centerY + amp * Math.sin((x * freq * 2 * Math.PI) / TILE + phase);
    pts.push(`${s === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`);
  }
  return pts.join(" ");
}

// Token-based stroke definitions; index cycles through --primary/--border/--foreground
// at varied opacity and weight to create the layered depth look.
// Theme-check: --primary is gild on dark, accent-ink on light; --border is subtle on
// both; --foreground is near-white on dark, ink on light → all read on both themes. ✓
const STROKE_DEFS = [
  { color: "var(--primary)", opacity: 0.22, width: 1.5 },
  { color: "var(--border)", opacity: 0.55, width: 0.8 },
  { color: "var(--foreground)", opacity: 0.10, width: 1.2 },
  { color: "var(--primary)", opacity: 0.30, width: 1.0 },
  { color: "var(--border)", opacity: 0.40, width: 0.6 },
  { color: "var(--foreground)", opacity: 0.16, width: 0.9 },
  { color: "var(--primary)", opacity: 0.18, width: 1.3 },
];

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

/**
 * Layered flowing sine-wave background layer.
 *
 * Several SVG sine-wave paths scroll horizontally in a seamless translateX loop,
 * producing a calm contour-lines effect. Wave geometry is deterministic → SSR-safe.
 * Animation is mount-gated (avoids React #418 hydration mismatch). An
 * IntersectionObserver pauses the animation when scrolled fully out of view.
 *
 * Decorative: root carries `role="presentation"` + `aria-hidden` + `pointer-events:none`.
 * Place sibling content at `z-10` above this `z-0` layer.
 *
 * @param lineCount - Wave lines rendered. Default 5.
 * @param amplitude - Max wave height in viewBox units. Default 14.
 * @param speed     - Seconds per scroll cycle. Default 12.
 */
export function WaveLines({
  lineCount = 5,
  amplitude = 14,
  speed = 12,
  className = "",
  style,
  "aria-hidden": ariaHidden = true,
  ...props
}: WaveLinesProps) {
  // ── Contract mount-gate + null-safe reduce (avoids React #418 + flash). ──
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null → reduced
  const animate = mounted && !reduce;

  // ── Pause when scrolled fully offscreen (saves GPU). ──
  const rootRef = useRef<HTMLDivElement>(null);
  const [running, setRunning] = useState(true);
  useEffect(() => {
    if (!animate) return;
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      ([entry]) => setRunning(entry.isIntersecting),
      { rootMargin: "0px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [animate]);

  // ── Pre-compute paths (deterministic — safe during render incl. SSR). ──
  const count = Math.max(1, Math.min(lineCount, 12));
  const paths = Array.from({ length: count }, (_, i) => ({
    d: buildWavePath(i, count, amplitude),
    stroke: STROKE_DEFS[i % STROKE_DEFS.length],
  }));

  return (
    <div
      ref={rootRef}
      role="presentation"
      aria-hidden={ariaHidden}
      className={[styles.root, className].filter(Boolean).join(" ")}
      style={style}
      {...props}
    >
      {/*
       * Single SVG, 200% wide, containing two identical wave-tile widths.
       * The .animating class adds `animation: waveScroll` (translateX 0 → -50%).
       * -50% of the SVG's own width = -100vw → reveals the second tile, which
       * is indistinguishable from the first → seamless seamless loop.
       *
       * On server + first client render (mounted=false): no .animating class →
       * static waves → byte-identical → zero hydration mismatch.
       *
       * Reduced-motion users: same — .animating never added; static contour lines.
       */}
      <svg
        className={[
          styles.svg,
          animate ? styles.animating : "",
        ]
          .filter(Boolean)
          .join(" ")}
        style={
          {
            "--wave-speed": `${speed}s`,
            // ponytail: animationPlayState for the IO pause; safe on SVG via inline style.
            animationPlayState: running ? "running" : "paused",
          } as React.CSSProperties
        }
        viewBox="0 0 200 100"
        preserveAspectRatio="none"
        xmlns="http://www.w3.org/2000/svg"
        aria-hidden="true"
      >
        {paths.map((p, i) => (
          <path
            key={i}
            d={p.d}
            stroke={p.stroke.color}
            strokeOpacity={p.stroke.opacity}
            strokeWidth={p.stroke.width}
            fill="none"
            // Keep stroke widths consistent in screen-pixels regardless of SVG scaling.
            vectorEffect="non-scaling-stroke"
          />
        ))}
      </svg>
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens