Wave Art
An eight layer wave in SVG and one CSS module, with no animation library. I give every stroke a path length of 1, so one keyframe draws them all with stroke-dashoffset, and a CSS variable per layer staggers the start by 80 ms. Each layer is ink at a stepped opacity. The group sways on an 11 second loop.
- 2026
- SVG
- CSS
- Motion
Controls
Speed
Layers
Sway
Source
wave-art.module.css114 lines
/*
Wave Art. SVG and CSS, no animation library.
Set inline by piece.tsx:
--wa-sway how far the group rises, in px
--wa-sway-dur one sway, 11s at speed 1
--wa-draw one stroke's draw time
--wa-delay wait before the first stroke
--wa-stagger gap between layers
--i the layer's place in the order, per <g>
--o the layer's stroke opacity, per <g>
The base state is the finished state: strokes drawn, bodies visible. The keyframes only say where
things come from, so without animation (reduced motion) nothing is left undrawn.
*/
.root {
/* For the stroke widths below: the viewBox stretches, so the width in viewBox units depends on the stage. */
container-type: size;
color: var(--ink);
}
.wave {
display: block;
/* Bodies reach below the viewBox. Visible overflow keeps the bottom edge covered while the group is up. */
overflow: visible;
/* On the <svg>, not on a <g>: here 1px is a CSS pixel, inside the viewBox it would be stretched. */
animation: wa-sway var(--wa-sway-dur, 11s) ease-in-out infinite;
/* Starts once the strokes are down. */
animation-delay: calc(var(--wa-delay, 0.3s) + var(--wa-draw, 1.6s));
will-change: transform;
}
/* A loop needs both ends slow, so this is the one place the site's ease-out curve does not fit. */
@keyframes wa-sway {
50% {
transform: translateY(calc(var(--wa-sway, 10px) * -1));
}
}
.line {
fill: none;
stroke: currentColor;
stroke-opacity: var(--o, 0.5);
stroke-linecap: round;
/* About 1.3 CSS px on a phone stage, where one viewBox unit is about 0.9 px. */
stroke-width: 1.4;
/* pathLength="1": the same two values draw every wave, whatever its real length. */
stroke-dasharray: 1;
stroke-dashoffset: 0;
animation: wa-draw var(--wa-draw, 1.6s) var(--ease-out-expo) both;
animation-delay: calc(var(--wa-delay, 0.3s) + var(--wa-stagger, 0.08s) * var(--i, 0));
}
/* One viewBox unit grows with the stage, so the stroke shrinks to stay near 1.5 px. */
@container (min-width: 520px) {
.line {
stroke-width: 0.9;
}
}
@container (min-width: 720px) {
.line {
stroke-width: 0.6;
}
}
/* The piece's one use of the accent. Full opacity: it is the signal. */
.accent {
stroke: var(--accent);
stroke-opacity: 1;
}
@keyframes wa-draw {
from {
stroke-dashoffset: 1;
}
}
/* The body under each line: ink fading to nothing, so stacked layers read as depth in either theme. */
.body {
stroke: none;
fill-opacity: calc(var(--o, 0.5) * 0.22);
animation: wa-body var(--wa-draw, 1.6s) var(--ease-out-expo) both;
animation-delay: calc(var(--wa-delay, 0.3s) + var(--wa-stagger, 0.08s) * var(--i, 0) + var(--wa-draw, 1.6s) * 0.4);
}
.stop {
stop-color: var(--ink);
}
@keyframes wa-body {
from {
opacity: 0;
}
}
/* The stage's pause button: everything stops where it is. */
.paused .wave,
.paused .line,
.paused .body {
animation-play-state: paused;
}
/* Strokes fully drawn, no sway. */
@media (prefers-reduced-motion: reduce) {
.wave,
.line,
.body {
animation: none;
}
}piece.tsx78 lines
"use client";
import { useId, type CSSProperties } from "react";
import type { DomPieceProps } from "@/entries/types";
import { cn } from "@/lib/utils";
import styles from "./wave-art.module.css";
/**
* Eight wave layers in SVG. From lab/ui-labs-bts, where motion/react drew the strokes and a warm
* gradient sat behind them. Here it is SVG and one CSS module: every stroke has a path length of 1,
* so one keyframe draws all of them, and `--i` staggers the start per layer. The colours are the
* site's: ink at stepped opacities on the surface, and the accent on one stroke.
*
* The viewBox stretches to the stage (preserveAspectRatio none), as in the original. The strokes do
* not use non-scaling-stroke: Chrome then measures dashes in screen pixels and pathLength stops
* working. The CSS module picks the stroke width by container width instead.
*/
/** `y` is the line the wave swings around; the body's fade is measured from it. `o` is the stroke opacity. */
const WAVES = [
{ d: "M-20,40 C 80,10 140,70 220,40 S 360,10 420,40", y: 40, o: 0.24 },
{ d: "M-20,70 C 60,105 160,35 240,75 S 380,110 420,75", y: 72, o: 0.32 },
{ d: "M-20,105 C 100,60 180,140 260,100 S 360,60 420,100", y: 102, o: 0.4 },
{ d: "M-20,140 C 70,180 170,105 250,145 S 370,185 420,145", y: 143, o: 0.5 },
{ d: "M-20,180 C 90,135 190,215 270,175 S 380,140 420,178", y: 178, o: 0.6 },
{ d: "M-20,215 C 60,255 160,180 240,220 S 360,260 420,218", y: 218, o: 0.7 },
{ d: "M-20,250 C 100,205 190,285 270,245 S 370,210 420,248", y: 248, o: 0.8 },
{ d: "M-20,282 C 70,320 170,248 250,285 S 370,320 420,282", y: 284, o: 0.9 },
] as const;
/** How far below its line a body reaches before it has faded to nothing, in viewBox units. */
const BODY = 120;
export default function WaveArt({ values, paused }: DomPieceProps) {
const gradient = useId();
const speed = typeof values.speed === "number" ? values.speed : 1;
const sway = typeof values.sway === "number" ? values.sway : 10;
const wanted = typeof values.layers === "number" ? values.layers : WAVES.length;
const count = Math.min(WAVES.length, Math.max(3, Math.round(wanted)));
// Fewer layers still span the stage: take them evenly from the eight, first and last included.
const shown = Array.from({ length: count }, (_, i) => Math.round((i * (WAVES.length - 1)) / (count - 1)));
// The accent goes to the middle layer on show, so there is exactly one at every count.
const accent = shown[Math.floor(count / 2)];
// Speed 0 cannot be a duration. It is a freeze.
const rate = speed > 0 ? speed : 1;
const vars = {
"--wa-sway": `${sway}px`,
"--wa-sway-dur": `${11 / rate}s`,
"--wa-draw": `${1.6 / rate}s`,
"--wa-delay": `${0.3 / rate}s`,
"--wa-stagger": `${0.08 / rate}s`,
} as CSSProperties;
return (
<div aria-hidden="true" className={cn(styles.root, "h-full w-full overflow-hidden", (paused || speed <= 0) && styles.paused)} style={vars}>
<svg className={cn(styles.wave, "h-full w-full")} viewBox="0 0 400 320" preserveAspectRatio="none" fill="none">
<defs>
{/* Bounding box units: every body is BODY tall under its line, so one gradient fades them all alike. */}
<linearGradient id={gradient} x1="0" y1="0" x2="0" y2="1">
<stop className={styles.stop} offset="0.2" stopOpacity="1" />
<stop className={styles.stop} offset="1" stopOpacity="0" />
</linearGradient>
</defs>
{shown.map((index, i) => {
const wave = WAVES[index];
return (
<g key={index} style={{ "--i": i, "--o": wave.o } as CSSProperties}>
<path className={styles.body} d={`${wave.d} V${wave.y + BODY} H-20 Z`} fill={`url(#${gradient})`} />
<path className={cn(styles.line, index === accent && styles.accent)} d={wave.d} pathLength={1} />
</g>
);
})}
</svg>
</div>
);
}