Backgrounds

Waves

An SVG wave divider and layered backdrop for section transitions and hero bottoms — pure CSS, no canvas.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * One shared drift loop: every layer's <svg> is drawn at 200% width with its
 * path repeating every 100 viewBox units, so translating exactly -50% lands
 * on an identical frame — the loop never visibly seams or resets.
 */
const KEYFRAMES = `@keyframes zy-waves-drift{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}`

/**
 * Back-to-front layer recipe. Each `d` is a hand-drawn cubic-bezier wave with
 * a 100-unit period (viewBox is 200 wide = two periods), staggered in crest
 * position and frequency so the stack reads as depth rather than a repeat.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/waves.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Waves" component — an SVG wave
divider/backdrop for section transitions and hero bottoms. Its only
dependency is a cn() class merger (clsx + tailwind-merge). No hooks, no
events, no browser APIs, so it must stay a server component — do NOT add
"use client".

Contract
- export function Waves(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root div) plus:
  - variant?: "divider" | "backdrop" (default "divider") — "divider" renders
    a standalone wave band with no content slot, meant to sit between two
    sections; "backdrop" fills the container with layered waves anchored to
    one edge and renders children above them.
  - layers?: 1 | 2 | 3 (default 3) — how many wave layers to stack, back to
    front.
  - animated?: boolean (default true) — horizontal drift; false renders a
    static wave.
  - flip?: boolean (default false) — mirrors the wave(s) vertically, for use
    at the top of the following section so two dividers sandwich a panel.
- children only apply to variant="backdrop"; the "divider" variant is pure
  decoration and ignores them.

Behavior
- Three hand-drawn layer recipes (back/mid/front), each a cubic-bezier path
  in a `viewBox="0 0 200 100"` `<svg preserveAspectRatio="none">`. Every path
  has a 100-unit period repeated twice across the 200-unit viewBox, so the
  <svg> can be rendered at width: 200% and translated by exactly -50% to loop
  seamlessly — the second period slides in identical to the first, so the
  drift never visibly resets or seams.
- `layers` slices the back-to-front recipe array from the end, so layers=1
  shows just the front (boldest, shortest, fastest) layer, layers=2 adds the
  mid layer, layers=3 shows all three. Crest position and frequency differ
  per layer (front is higher-frequency and shorter-amplitude than back) so
  the stack reads as depth rather than a single wave repeated three times.
- animated=true: each <svg> gets one shared @keyframes loop
  (translateX(0) -> translateX(-50%)) via a Tailwind arbitrary-value class,
  with a per-layer duration passed in as a CSS custom property
  (--zy-waves-duration) set through inline style — back is slowest (24s),
  front is fastest (12s), so the layers visibly drift at different speeds
  instead of moving in lockstep.
- The @keyframes ship inside the component via a React 19 hoisted
  <style href="zyeon-waves" precedence="medium"> tag — no Tailwind config
  edits, and multiple instances dedupe to one style tag by href.
- prefers-reduced-motion: the animation class carries a
  motion-reduce:[animation:none] pair at the same specificity as the
  animated class (not an inline style override, which reduced-motion could
  not out-rank) — the wave stays visible and static, no functional loss.
- variant="divider": the root div itself is h-16 sm:h-24 w-full
  overflow-hidden and aria-hidden="true" (the whole element is decoration,
  there is no content slot). flip applies -scale-y-100 to that same root.
- variant="backdrop": the root is relative isolate overflow-hidden; an
  aria-hidden, pointer-events-none absolute layer (inset-x-0 bottom-0, h-2/3
  sm:h-3/4) holds the wave <svg>s, with flip repositioning that layer to
  top-0 and mirroring it (-scale-y-100) instead of moving children. Children
  render in a separate relative z-10 wrapper above the wave layer, so the
  waves never intercept clicks or sit over text.

Rendering & styling
- Semantic tokens only. Each layer's fill is one of fill-primary/10,
  fill-primary/20, fill-primary/30 — back to front, alpha rising with each
  layer so the closest wave reads as most solid. No hex / rgb() / oklch()
  anywhere, so the waves re-skin themselves with the host theme and dark
  mode automatically.
- Merge consumer className via cn() on the root div — for "divider" this
  sets the band's own background (match the section above it so the flat
  area above the wave crest reads as a seamless continuation); for
  "backdrop" it sets the container's background/rounding/border.

Customization levers
- Layer count and speed: layers picks how many of the three recipes render;
  each recipe's duration field is the single knob for that layer's drift
  speed, independent of the others.
- Shape: each layer's `d` is a self-contained hand-drawn path with a
  100-unit period — swap amplitude (the control-point y offsets) or
  frequency (add more control-point pairs per 100 units) without touching
  the animation or layering logic, as long as the shift by -50% still lands
  on an identical period.
- Palette: swap fill-primary/* for var(--chart-1..5) or --secondary/--accent
  tokens to re-tint the waves independent of the primary color.
- Sandwich usage: pair two "divider" instances — flip on the one that opens
  a panel, plain on the one that closes it — to bookend a section with a
  consistent wave silhouette top and bottom.
- Static usage: set animated={false} anywhere a still illustration is
  preferred over motion (e.g. print/export views, or alongside other moving
  decoration to avoid competing animations).

Concepts

  • Seamless double-width loop — every layer's path repeats every 100 viewBox units; the <svg> is drawn at 200% width (two periods) and translated by exactly -50%, so the frame at the end of the loop is pixel-identical to the frame at the start — no jump, no visible seam, regardless of loop duration.
  • Layered parallax silhouette — three recipes (back/mid/front) differ in crest position, frequency and alpha, and each drifts at its own duration; stacking them reads as depth rather than a single wave copy-pasted three times.
  • Divider vs. backdropvariant="divider" is a standalone, fully decorative band with no content slot (aria-hidden on the root itself); variant="backdrop" anchors the same wave layers to one edge of a container and renders children in a separate z-10 layer above them.
  • Vertical flip for sandwichingflip mirrors the wave vertically instead of re-drawing it, so the same component can open a panel (flipped) and close it (plain) with a matching silhouette top and bottom.
  • Reduced-motion honesty — the drift animation and its motion-reduce:[animation:none] override live in the same Tailwind arbitrary-value class (not a competing inline style), so reduced-motion reliably freezes the wave in place instead of losing to specificity — the shape stays visible, only the motion stops.
  • Server-component purity — no hooks, no events, no "use client": the wave renders on the server and animates entirely in CSS.

On This Page