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

How To Use It
Install the package with NPM.
npm install github:angelolibero/surface-fieldThe 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.
| Method | Use |
|---|---|
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
| Prop | Effect |
|---|---|
gap | Dot spacing; default 22 CSS pixels |
focusRadius, lineRadius | Focal-light reach (default 250 CSS pixels) and line reach (default focusRadius * 0.25) |
tint | Focal accent blend; default 0.22, with 0 to disable it |
controller | Stable controller returned by createSurfaceFieldController() |
interactionRoot | Ref to the element that bounds primary-press interactions; the canvas remains decorative |
style | Size, position, and inherited dot color on the field container |
viewport | Grid camera with x, y, and zoom, unless the controller owns it |
connected | Curved lines between displaced grid dots |
baseOpacity, maxOpacity | Ambient and focal brightness |
cursorPush, cursorPushRadius | Pointer-driven dot displacement |
ripplePush, rippleBoost, rippleGrow | Ripple displacement, brightness, and dot growth |
surfacePadding | Adjust the empty fade band outside object rectangles |
breathe, wander, still | Breathing 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()} />;
