Surface Field: Interactive Dot Background for React

Description:

Surface Field is a React canvas component that draws a decorative field of dots and lines around cards and other positioned objects.

The field lights up beneath the pointer, sends out press ripples, and bends around moving objects when you provide their geometry.

Preview

surface-field-dot-background

How To Use It

Install the package with NPM.

npm install github:angelolibero/surface-field

The canvas has pointer-events: none and an aria-hidden root. It pauses while the document is hidden and draws a still frame for reduced-motion preferences.

Give the parent a nonzero size and set the inherited CSS color for the dots:

import { SurfaceField } from "surface-field";
export function Backdrop() {
  return (
    <div
      style={{
        position: "relative",
        minHeight: 500,
        background: "#101318",
        color: "#e5e7eb",
      }}
    >
      <SurfaceField
        connected
        gap={22}
        focusRadius={600}
        lineRadius={200}
        baseOpacity={0.02}
        maxOpacity={0.2}
        tint={0}
        breathe={0}
        wander={false}
        cursorPush={2}
        ripplePush={6}
        style={{ position: "absolute", inset: 0 }}
      />
      <div style={{ position: "relative", padding: 32 }}>
        Workspace content
      </div>
    </div>
  );
}

Clear the Field Around Cards

The backdrop does not detect DOM objects automatically. Create one stable controller per mounted field and publish rectangles from your app’s layout or gesture code. setScene rectangles use CSS pixels relative to the supplied root’s border box.

import { useLayoutEffect, useMemo, useRef } from "react";
import {
  SurfaceField,
  createSurfaceFieldController,
} from "surface-field";
export function CardBackdrop() {
  const root = useRef<HTMLDivElement>(null);
  const controller = useMemo(createSurfaceFieldController, []);
  useLayoutEffect(() => {
    if (!root.current) return;
    controller.setScene({
      root: root.current,
      rects: [
        {
          id: "card-1",
          parent: null,
          left: 40,
          top: 40,
          right: 280,
          bottom: 180,
          radius: 16,
        },
      ],
    });
    return () => controller.setScene(null);
  }, [controller]);
  return (
    <div ref={root} style={{ position: "relative", height: 360 }}>
      <SurfaceField
        controller={controller}
        interactionRoot={root}
        connected
        style={{ position: "absolute", inset: 0, color: "#777" }}
      />
      <div
        style={{
          position: "absolute",
          left: 40,
          top: 40,
          width: 240,
          height: 140,
          borderRadius: 16,
          background: "#ffffff",
        }}
      >
        Card content
      </div>
    </div>
  );
}

Controller and Editor Integration

Update the scene when committed object geometry changes. During dragging, send viewport-relative rectangles through setFootprint; these coordinates differ from the root-relative rectangles passed to setScene.

Once setViewport is called, the controller owns the camera and the viewport prop is ignored for that field. Calls within an animation frame are coalesced.

Use ids for carried scene-object IDs, initial: true to reuse the press capture box, and suppressRipple when another gesture owns that press. A preview with committed: true fades its light after commitment.

MethodUse
setScene({ root, rects })Publish positioned objects and their IDs; null clears the scene
setFootprint({ pointerId, rects, ids?, initial?, suppressRipple? })Publish viewport-relative carried rectangles; clear with an empty rects array for that pointer
setPreview({ rect, committed? })Show a viewport-relative preview; end it with { rect: null }
setViewport({ x, y, zoom })Move the grid with an editor camera
setLinks(links)Draw channels between objects; pass null to clear
refreshTheme()Reread inherited dot and accent colors

Geometry and Link Data

For object shapes, add radius for rounded rectangles, shape: "ellipse" for an inscribed ellipse, or clockwise rotation in degrees. Rectangles remain in their unrotated coordinate box.

Link objects accept from/to scene IDs or a points polyline in scene-root coordinates. Optional width, strength, motion (still, loop, or bounce), speed, and sequence control the channel. points takes precedence over the endpoint IDs.

For React Flow, place the field behind the graph and forward camera and node rectangles to the controller. Start from examples/react-flow/ for node dragging, resizing, and camera synchronization.

Component Props

PropEffect
gapDot spacing; default 22 CSS pixels
focusRadius, lineRadiusFocal-light reach (default 250 CSS pixels) and line reach (default focusRadius * 0.25)
tintFocal accent blend; default 0.22, with 0 to disable it
controllerStable controller returned by createSurfaceFieldController()
interactionRootRef to the element that bounds primary-press interactions; the canvas remains decorative
styleSize, position, and inherited dot color on the field container
viewportGrid camera with x, y, and zoom, unless the controller owns it
connectedCurved lines between displaced grid dots
baseOpacity, maxOpacityAmbient and focal brightness
cursorPush, cursorPushRadiusPointer-driven dot displacement
ripplePush, rippleBoost, rippleGrowRipple displacement, brightness, and dot growth
surfacePaddingAdjust the empty fade band outside object rectangles
breathe, wander, stillBreathing dots, autonomous light drift, and fixed light position

Optional Worker Rendering

The field transfers its canvases to OffscreenCanvas. If the worker or required browser APIs are unavailable, drawing falls back to the main thread. Each mounted field owns one worker and terminates it on unmount.

With Vite, import the worker through its worker suffix and pass a factory to the field:

import FieldWorker from "surface-field/worker?worker";
<SurfaceField worker={() => new FieldWorker()} />;

Alternatives and Related Resources

Add Comment