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-linesUsage
Usagetsx
<WaveLines />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| lineCount | number | 5 | Number of wave lines stacked vertically. Default 5. Max clamped to 12. |
| amplitude | number | 14 | Wave amplitude in viewBox units (0–40). Default 14. |
| speed | number | 12 | Seconds 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