Skip to content

FreeIn reviewv1.0.0

Dot Waves

A halftone field whose dots swell and shrink on a travelling wave, with a swell under the pointer.
npx shadcn@latest add @vibebox/dot-waves

Controls

1
1
<DotWaves />

About

A staggered grid; each dot's centre is evaluated as a position in the field, where two crossing sines and a little noise make a value in 0 to 1, and the pointer adds a Gaussian swell. That value sets the dot's radius (through a contrast curve) and its colour between the two dot colours. The edge is antialiased with a width derived from the cell size.

Use it when

  • A SaaS, analytics or fintech page wants a calm, printed texture.
  • A design studio wants a halftone look.
  • A report or a data page wants motion without noise.

Skip it when

  • The brand is organic and soft.
  • Text must sit over the biggest dots.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/dot-waves
  3. 3Use it:
    import { DotWaves } from "@/components/vibebox/dot-waves";
    
    <DotWaves preset="ink" className="min-h-[560px] rounded-xl">
      <div className="p-12 text-white">
        <h1 className="text-4xl font-semibold">Numbers, in motion.</h1>
      </div>
    </DotWaves>

Code

3 files
components/vibebox/dot-waves/dot-waves.client.tsx
/**
 * VibeBox Motion · Dot Waves
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import type { ReactNode } from "react";
import { BackdropCanvas, type PosterSpec } from "@/lib/vibebox/backdrop-canvas";
import { dotWavesPiece, presets, type DotWavesPreset } from "./dot-waves.shader";

export type DotWavesColor = "paperD" | "dotA" | "dotB";
export type DotWavesValue = "cellsD" | "wave" | "contrast";

export type DotWavesProps = {
  /** What sits on the scene: a heading, a call to action, a whole section. */
  children?: ReactNode;
  /** A named look: `ink`, `cyan`, `slate`, `rose`, `paper`. */
  preset?: DotWavesPreset;
  /** Overrides single palette colours with any CSS colour. */
  colors?: Partial<Record<DotWavesColor, string>>;
  /** Multiplies the preset's speed (1 keeps it, 0 holds the scene still). */
  speed?: number;
  /** Multiplies the preset's brightness. */
  intensity?: number;
  /** The pointer and touch move the scene. */
  interactive?: boolean;
  /** Accessible name; leave it out when the scene is only a background. */
  label?: string;
  /** Hold one still frame until this is false (for a gallery card). */
  still?: boolean;
  className?: string;
} & Partial<Record<DotWavesValue, number>>;

const POSTER: PosterSpec = { type: "linear", from: "paperD", to: "paperD", angle: 180, blobs: [{ color: "dotA", at: [30, 60], size: 50 }, { color: "dotB", at: [72, 35], size: 40 }] };

export function DotWaves({ children, preset, colors, speed, intensity, interactive = true, label, still, className, cellsD, wave, contrast }: DotWavesProps) {
  return (
    <BackdropCanvas
      slug="dot-waves"
      piece={dotWavesPiece}
      presets={presets}
      fallback="ink"
      preset={preset}
      colors={colors}
      values={{ cellsD, wave, contrast }}
      speed={speed}
      intensity={intensity}
      poster={POSTER}
      scale={0.5}
      stillTime={3}
      still={still}
      interactive={interactive}
      label={label}
      className={className}
    >
      {children}
    </BackdropCanvas>
  );
}
components/vibebox/dot-waves/dot-waves.shader.ts
/**
 * VibeBox Motion · Dot Waves
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { compose, createScenePiece, type ScenePreset } from "@/lib/vibebox/engine/backdrops";

/*
 * A halftone field: a grid of dots whose size follows a travelling wave, with the colour running
 * from one end of the palette to the other along the wave. It reads as a printed gradient that
 * breathes. The pointer drops a swell in the field.
 */

const SCENE = /* glsl */ `
uniform vec3 uPaperD;
uniform vec3 uDotA;
uniform vec3 uDotB;
uniform float uCellsD;
uniform float uWave;
uniform float uContrast;
uniform float uSpeed;
uniform float uIntensity;

float T;

void main() {
  vec2 uv = centred(gl_FragCoord.xy);
  T = uTime * uSpeed;
  float aspect = uResolution.x / uResolution.y;
  vec2 pu = vec2((uPointer.x - 0.5) * aspect, uPointer.y - 0.5);

  float cells = uCellsD * 36.0;
  vec2 g = uv * cells;
  // A staggered grid reads smoother than a square one.
  g.x += mod(floor(g.y), 2.0) * 0.5;
  vec2 id = floor(g);
  vec2 f = fract(g) - 0.5;
  vec2 centre = (id + 0.5 - vec2(mod(id.y, 2.0) * 0.5, 0.0)) / cells;

  // The wave: two crossing swells and a slow drift of noise, with the pointer's swell on top.
  float w = 0.5 + 0.5 * sin(centre.x * 3.2 * uWave + T * 0.6 + sin(centre.y * 2.0 + T * 0.3) * 1.2);
  w = w * 0.6 + 0.4 * (0.5 + 0.5 * sin(centre.y * 5.0 * uWave - T * 0.5 + centre.x * 2.0));
  w += 0.35 * (fbm3(centre * 2.0 + vec2(T * 0.05, 0.0)) - 0.5);
  float d = length(centre - pu);
  w += uHover * 0.5 * exp(-d * d * 8.0);
  w = clamp(w, 0.0, 1.0);

  float radius = mix(0.04, 0.46, pow(w, uContrast));
  float dist = length(f);
  float aa = 1.5 / (uResolution.y / cells);
  float dotMask = smoothstep(radius + aa, radius - aa, dist);

  vec3 col = lin(uPaperD);
  col = mix(col, mix(lin(uDotA), lin(uDotB), w), dotMask);

  col *= uIntensity;
  col *= 1.0 - 0.2 * smoothstep(0.6, 1.2, length(uv * vec2(0.85, 1.1)));
  fragColor = vec4(finish(col, gl_FragCoord.xy), 1.0);
}
`;

export const fragment = compose(SCENE);

export const PRESET_NAMES = ["ink", "cyan", "slate", "rose", "paper"] as const;
export type DotWavesPreset = (typeof PRESET_NAMES)[number];

export const presets: Record<DotWavesPreset, ScenePreset> = {
  ink: {
    colors: { paperD: "#070610", dotA: "#3a2a8a", dotB: "#b9a6ff" },
    values: { cellsD: 1, wave: 1, contrast: 1.2, speed: 0.8, intensity: 1.1 },
  },
  cyan: {
    colors: { paperD: "#03121a", dotA: "#0d6a8a", dotB: "#6fffe0" },
    values: { cellsD: 1, wave: 1, contrast: 1.1, speed: 0.9, intensity: 1.1 },
  },
  slate: {
    colors: { paperD: "#0e1118", dotA: "#3a4458", dotB: "#cfd6e6" },
    values: { cellsD: 0.9, wave: 0.9, contrast: 1.3, speed: 0.6, intensity: 1.05 },
  },
  rose: {
    colors: { paperD: "#14060d", dotA: "#8a1f4a", dotB: "#ffb3cf" },
    values: { cellsD: 1, wave: 1.1, contrast: 1.2, speed: 0.8, intensity: 1.1 },
  },
  paper: {
    colors: { paperD: "#f4efe6", dotA: "#c9442a", dotB: "#1b1b1b" },
    values: { cellsD: 0.95, wave: 1, contrast: 1.2, speed: 0.6, intensity: 1 },
  },
};

export const dotWavesPiece = createScenePiece("dot-waves", fragment);
components/vibebox/dot-waves/index.tsx
/**
 * VibeBox Motion · Dot Waves
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { DotWaves, type DotWavesProps, type DotWavesColor, type DotWavesValue } from "./dot-waves.client";
export { PRESET_NAMES as DOT_WAVES_PRESETS, presets as dotWavesPresets, type DotWavesPreset } from "./dot-waves.shader";

Props

PropTypeDefaultDescription
childrenReactNode—What sits on the scene: a heading, a call to action, a whole section.
preset"ink" | "cyan" | "slate" | "rose" | "paper""ink"The look: ink, cyan, slate, rose, paper.
colors{ paperD?: string; dotA?: string; dotB?: string }—Overrides single palette colours with any CSS colour: paperD (the paper), dotA (the dots where the wave is low), dotB (the dots where the wave is high).
speednumber1Multiplies the preset's speed; 0 holds the scene still.
intensitynumber1Multiplies the preset's brightness, 0.5 to 1.4.
cellsDnumber1How fine the dot grid is (0.5 to 1.8; each look sets its own).
wavenumber1How many swells cross the frame (0.4 to 2; each look sets its own).
contrastnumber1.2How much dot sizes differ (0.7 to 2; each look sets its own).
interactivebooleantrueThe pointer drops a swell in the field.
labelstring—Accessible name; leave it out when the scene is only a background.
stillbooleanfalseHold one composed frame until false (a gallery card that plays on hover).
classNamestring—Merged with cn(); give it a size and a radius.

Accessibility and motion

Keyboard
Not focusable; content on it keeps its own focus order.
Screen readers
Decorative by default; with label it is an image with that name.
Touch
A finger moves the scene like the cursor does; scrolling is never blocked.
Reduced motion
One still frame of the field; the wave does not travel.

Edge cases and limits

  • Paper is a print look and needs dark text.
  • Very high contrast makes the small dots vanish.
  • Fine grids (cellsD over 1.4) shimmer on low-resolution canvases.
  • Many thin cyan, violet and magenta lines of light flowing across a dark background in one wave

    Dozens of thin lines of light riding one slow wave, bunching where they cross, with a glow that follows the pointer.

    Backgrounds · WebGL2

  • A pink and gold striped sun over a cyan grid and a mountain ridge

    A glowing grid running to a striped sun and a ridge of mountains, banked by the pointer.

    Backgrounds · WebGL2

  • Green and violet aurora curtains over a mountain ridge and stars

    Aurora

    Free

    Curtains of northern light over a night sky and a mountain ridge, drawn toward the pointer.

    Backgrounds · WebGL2

↑ ↓ to move↵ to openesc to close