freeNew134 lines · framer-motion
Waves Lines
Layered wave lines drifting at their own pace. A full-width section backed by several wavy SVG line layers scrolling sideways on a seamless loop at different speeds and opacities for parallax depth, under a short glass-card headline.
- Use it for
- A calm section break for product, audio or data-adjacent pages that wants a subtle sense of signal or rhythm without a heavy asset.
- Themes
- Light + dark
- License
- Commercial use
Live
Signal
Steady motion, layer after layer
Wavy line layers drifting sideways at their own pace.
tsx
"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width section backed by layered wavy line patterns that scroll sideways on a
* seamless loop, each layer a different speed, opacity and stroke for parallax depth.
* A glass card holds a short headline on top. Pure SVG + CSS, no canvas.
*/
export interface BgWavesLinesProps {
eyebrow?: string;
headline?: string;
subcopy?: string;
/** Scroll speed multiplier. */
speed?: number;
/** Wave height in px. */
amplitude?: number;
/** How many wave layers stack for parallax. */
layers?: number;
className?: string;
}
const TILE_WIDTH = 480;
const CYCLES = 3;
function wavePath(height: number, phase: number) {
const freq = (Math.PI * 2 * CYCLES) / TILE_WIDTH;
const mid = height / 2;
const step = 4;
const pts: string[] = [];
for (let x = 0; x <= TILE_WIDTH; x += step) {
const y = mid + Math.sin(x * freq + phase) * mid * 0.86;
pts.push(`${x},${y.toFixed(2)}`);
}
return `M${pts.join(" L")}`;
}
const STROKES = ["stroke-muted-foreground/30", "stroke-border", "stroke-signal/40", "stroke-info/35"];
export function BgWavesLines({
eyebrow = "Signal",
headline = "Steady motion, layer after layer",
subcopy = "Wavy line layers drifting sideways at their own pace.",
speed = 1,
amplitude = 26,
layers = 3,
className,
}: BgWavesLinesProps) {
const reduce = useReducedMotionSafe();
const hostRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(hostRef, { amount: 0.15 });
const running = !reduce && inView;
const n = Math.max(2, Math.min(4, layers));
const defs = React.useMemo(
() =>
Array.from({ length: n }, (_, i) => ({
i,
height: Math.max(12, amplitude * (0.6 + i * 0.25)),
phase: i * 1.3,
duration: (26 - i * 4) / speed,
reverse: i % 2 === 1,
top: 10 + i * (74 / Math.max(1, n - 1)),
})),
[n, amplitude, speed],
);
return (
<section ref={hostRef} className={cn("relative isolate overflow-hidden bg-background", className)}>
<div className="absolute inset-0" aria-hidden>
{defs.map((d) => {
const path = wavePath(d.height, d.phase);
return (
<div
key={d.i}
className="absolute left-0 flex w-[200%]"
style={{
top: `${d.top}%`,
animation: "lk-marquee linear infinite",
animationDuration: `${d.duration}s`,
animationDirection: d.reverse ? "reverse" : "normal",
animationPlayState: running ? "running" : "paused",
}}
>
{[0, 1].map((rep) => (
<svg
key={rep}
viewBox={`0 0 ${TILE_WIDTH} ${d.height}`}
preserveAspectRatio="none"
className="h-10 w-1/2 shrink-0 sm:h-14"
style={{ height: d.height }}
>
<path d={path} fill="none" className={STROKES[d.i % STROKES.length]} strokeWidth={2} />
</svg>
))}
</div>
);
})}
</div>
<div className="absolute inset-0 bg-gradient-to-b from-background/20 via-background/0 to-background/65" aria-hidden />
<div className="relative mx-auto flex min-h-[22rem] max-w-3xl flex-col items-center justify-center px-4 py-20 text-center sm:min-h-[26rem] sm:px-6">
<motion.div
initial={reduce ? false : { opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 0.5 }}
className="rounded-[2rem] border border-border/60 bg-background/55 px-8 py-10 backdrop-blur-sm sm:px-14 sm:py-12"
>
{eyebrow && (
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
<span className="size-1.5 rounded-full bg-signal" aria-hidden />
{eyebrow}
</span>
)}
<h2 className="mt-4 font-display text-[clamp(2rem,5vw,3.4rem)] font-extrabold leading-[1.03] tracking-[-0.03em]">{headline}</h2>
{subcopy && <p className="mt-4 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>}
</motion.div>
</div>
</section>
);
}
export default BgWavesLines;
How it behaves
- →Each layer is one SVG sine-wave tile duplicated side by side and scrolled with the shared lk-marquee keyframe for a seamless loop; alternating layers run in reverse for parallax.
- →layers sets how many wave bands stack (2–4); each gets its own height, phase and speed so they never sync up.
- →amplitude scales wave height; speed scales scroll duration for every layer.
- →The loop runs only while the section is in view (framer-motion useInView) and pauses via animation-play-state when offscreen, the tab is hidden, or prefers-reduced-motion is set.
Accessibility
- ✓The wave layers are aria-hidden; only the heading and copy are in the accessibility tree.
- ✓Headline sits on a translucent blurred card for contrast against the lines in both themes.
- ✓Honours prefers-reduced-motion.