Display

Wave Divider

A live seam between two sections — stacked wave layers re-sampled every frame from two counter-travelling harmonics, with an opaque front wave painted in the next section's own token.

Preview in your theme

Loading preview…

"use client"

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

const TAU = Math.PI * 2
const MAX_LAYERS = 6
/** A backgrounded tab resumes with a huge gap — clamp so the seam never jumps a whole crest. */
const MAX_DT = 1 / 30
/** One polyline sample per this many CSS px of width, at most. */
const PX_PER_SAMPLE = 10
/** …and never fewer than this many per crest, or a short wave visibly facets. */
const SAMPLES_PER_WAVE = 10
const MIN_SAMPLES = 12

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/wave-divider.json

Prompt

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

Build a React + TypeScript + Tailwind "WaveDivider" component — an animated
section seam. Its only dependency is a cn() class merger (clsx +
tailwind-merge). It needs hooks and browser APIs, so it is a client
component ("use client").

Contract
- export function WaveDivider(props): props extend
  Omit<React.ComponentProps<"div">, "children"> (rest props spread onto the
  root div; there is no content slot) plus:
  - count?: number (default 3) — wave layers stacked back to front, clamped
    1-6. With one layer you get the front recipe.
  - amplitude?: number (default 1) — multiplier on how far every crest
    swings from its baseline. 0 flattens the seam to a straight edge.
  - speed?: number (default 1) — multiplier on the drift. 0 composes one
    static frame and never starts the loop.
  - wavelength?: number (default 260) — nominal CSS px between crests of
    the front layer; back layers stretch it to roughly double.
  - flip?: boolean (default false) — mirror vertically, so the fill hangs
    from the top edge instead of rising from the bottom.
  - colors?: string[] (default ["chart-1", "chart-2", "background"]) —
    theme token names without the leading "--". The LAST entry paints the
    front wave, which is opaque; the entries before it cycle across the
    translucent layers behind, back to front.
  - seed?: number (default 7) — integer seed for phases, amplitudes and
    wavelengths. Same seed, same seam.
- Clamp every numeric prop up front and treat a non-finite value as the
  default: 400 layers would build 400 paths per frame, a 0px wavelength
  asks for infinitely many crests, NaN silently erases the seam.

Behavior
- Layer table: a deterministic integer hash addressed by (layerIndex, salt)
  — never Math.random() — produces per-layer baseline, swing amplitude,
  wavelength multiple, drift multiple and two phase offsets. Store them as
  fractions of the band, not pixels, so a resize rescales the stack for
  free. Back layers are taller, longer and slower; front layers are
  shorter, busier and faster, which is what reads as depth rather than one
  wave stamped N times.
- Crest line: y(x) = baseline - amp * (sin(k1*x + t1) + 0.38*sin(k2*x + t2)),
  sampled across the measured width and closed down the right edge, along
  the bottom and back up the left. There is no tile and no repeat period to
  line up: the shape is continuous at 320px and at 2560px alike. The sample
  step is min(10px, wavelength/10) clamped to 12-400 samples, so a short
  wave never facets into a zig-zag and a lazy swell is never over-sampled.
- Drift: time enters as a phase shift. The primary harmonic travels one
  way, the second travels the other way at ~0.55x the rate, so crests swell
  and collapse as they pass instead of sliding by as a rigid stencil — that
  is the difference between an undulating seam and a moving stencil.
- Swing limits: cap each layer's amplitude at both (baseline - 2px)/(1 + 0.38)
  and (bandHeight - baseline - 2px)/(1 + 0.38), so however bold the caller asks
  for, no crest is sliced flat by overflow-hidden and no trough sinks past the
  near edge. A flat-topped wave reads as a bug rather than a bold one, and a
  trough that leaves the band is worse: the fill below the crest line
  disappears there, tearing a see-through notch through the seam.
- Sizing: a ResizeObserver on the inner stage sets the SVG viewBox to the
  measured box 1:1, so every number in the path is a CSS pixel and
  `wavelength` means the same thing in a small card and a wide hero.
  observe() fires once immediately, which doubles as the initial sizing and
  as the re-measure after a prop change re-runs the effect.
- Loop discipline: one requestAnimationFrame loop, dt clamped to 1/30s so a
  backgrounded tab cannot resume with a whole-crest jump. It is suspended
  when an IntersectionObserver reports the band off screen and when
  document.visibilitychange reports the tab hidden, and every observer,
  listener and frame handle is torn down on unmount.
- Paths are written with setAttribute inside the loop; React never
  re-renders per frame. The simulated clock lives in a ref, so changing
  speed or amplitude re-runs the effect without teleporting the seam.
- SSR + reduced motion: the frame React renders (and therefore the server
  HTML) is composed from props and the seed alone at a fixed simulated
  second on a nominal 1200x96 box, stretched by preserveAspectRatio="none"
  until the real box is measured — the seam is never a blank strip before
  hydration, and it still draws with JavaScript off. Read
  prefers-reduced-motion through matchMedia with a change listener
  (useSyncExternalStore, server snapshot false) so a mid-session flip is
  respected; when it is set, compose that same frame and never start the
  loop. The wave stays fully visible; only the motion stops.

Rendering & styling
- Semantic tokens only, no hex / rgb() / oklch(). Each layer's fill is
  `color-mix(in oklab, var(--<token>) <alpha>%, transparent)` on the path's
  style. The front layer is opaque (100%); the ones behind fade from ~50%
  down to ~18%, back to front.
- Root: aria-hidden="true", pointer-events-none relative block h-24 w-full
  overflow-hidden, with the consumer's className merged through cn() — that
  className is how the band's own background (the section on THIS side of the
  seam) and its height are set. Inner stage: absolute inset-0, plus
  -scale-y-100 when flip is set. SVG: absolute inset-0 size-full,
  preserveAspectRatio="none".
- The whole band is decoration: aria-hidden, pointer-events-none on the ROOT
  (not just on an inner node — the band is meant to be stretched over a hero,
  where an aria-hidden element with default pointer-events would still
  hit-test and swallow clicks on the CTA underneath it), no content slot, no
  pointer handlers, and nothing focusable to trap.

Customization levers
- Density: `count` 1-6 layers. One opaque layer is the classic wave
  divider; 4-6 with a chart palette is a rough sea.
- Motion: `speed` scales the drift (0 = a still illustration alongside
  other moving decoration); BASE_DRIFT (px/s) is the single constant behind
  it, and the per-layer drift multiple in the layer table is what makes the
  stack parallax instead of marching in lockstep.
- Shape: `amplitude` and `wavelength` are the two shape knobs; the
  HARMONIC_2 weight (0.38) and HARMONIC_2_RATE (0.55) control how much the
  seam morphs versus simply travelling. Drop HARMONIC_2 to 0 for a pure
  travelling sine.
- Palette: `colors` takes theme tokens — put the destination section's
  token last for an invisible join, or a brand token last for a solid
  coloured wave. The ALPHA_BACK / ALPHA_MID pair is where you re-tune how
  loud the layers behind it read.
- Geometry: `flip` mirrors the seam, so the same component can close the
  section above (flipped) and open the one below (plain); `seed` reshuffles
  every phase and jitter without touching the recipe.
- Height belongs to className (h-24 default, h-32 sm:h-40 for a hero
  bottom) — the layer table is expressed in fractions, so the silhouette
  keeps its proportions at any band height.

Concepts

  • Two-harmonic crest line — each layer is the sum of two sines travelling in opposite directions at different rates, so the silhouette deforms as it moves. A single translated path (the usual wave divider) slides a fixed stencil sideways; this one actually undulates, and never repeats.
  • Per-frame resampling in CSS px — the viewBox tracks the measured box 1:1, so wavelength is a real pixel distance rather than a fraction of the container. The same divider keeps its wave size on a 320px card and a 2560px hero, and there is no tile boundary to make seamless because there is no tile.
  • Opaque front seam — the last token in colors paints the front layer at full opacity. Point it at the surface on the other side of the band and the two sections join with no visible edge; the translucent layers behind it are what read as depth.
  • Seeded layer table — every baseline, amplitude, wavelength and phase comes from a hash of (layer index, salt) rather than from a random stream, so the same seed always produces the same seam, a re-render never reshuffles it, and the server and client agree on the pre-hydration frame. What the seed pins is the jitter, not the silhouette: each layer's recipe is a function of its depth in the stack, so raising count re-shapes the whole stack rather than adding layers behind the existing ones.
  • Still frame as the honest fallback — the frame React renders is composed at a fixed simulated second from props alone. It is what ships in the HTML, what a JavaScript-less client keeps, and what prefers-reduced-motion: reduce settles on — the seam is never missing, only motionless.
  • Suspended, not throttled — the loop is cancelled outright when the band scrolls off screen or the tab goes hidden, and restarted from a fresh timestamp; the dt clamp means a tab resumed after five minutes advances one frame, not five minutes of crest travel.

On This Page