Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:42Get Lifetime →
loopkit
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.