Skip to content
Buse Turgay Sönmez

Design Engineer, Belgrade

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
1.00×
Layers
8
Sway
10 px

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>
  );
}
--ground
oklch(0.985 0.003 85)
--surface
oklch(0.958 0.005 85)
--raised
oklch(1 0 0)
--ink
oklch(0.2 0.006 85)
--muted
oklch(0.43 0.008 85)
--faint
oklch(0.5 0.008 85)
--line
oklch(0.9 0.004 85)
--line-strong
oklch(0.6 0.006 85)
--accent
oklch(0.66 0.22 38)
--accent-ink
oklch(0.52 0.19 38)
--stage
oklch(0.152 0.004 85)
--radius-stage
36px
--radius-stage-sm
28px
--radius-media
24px
--radius-panel
16px
--radius-field
10px
--ease-out-expo
cubic-bezier(0.16, 1, 0.3, 1)

This week · W39One WebGL context, three leaksGeometry · 2026