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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/wave-divider.jsonPrompt
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
viewBoxtracks the measured box 1:1, sowavelengthis 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
colorspaints 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
seedalways 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 raisingcountre-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: reducesettles 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
dtclamp means a tab resumed after five minutes advances one frame, not five minutes of crest travel.
Logo Morph
One SVG shape that travels between several logo or icon paths — normalised at runtime so the points pair up, and cross-fading instead of tearing when they cannot.
Line Draw on Scroll
SVG paths that draw themselves as the block scrolls through the viewport, dashoffset unwinding stroke by stroke.