Sand Cursor

v1.0.1
FreeInteractivityWebGL

An interactive WebGL sand canvas particle field that deforms dynamically on pointer movement using React Three Fiber.

Sand Cursor

Installation

Step 1. Initialize ProjectOne-time setup

Run once in your project root to configure paths and dependencies:

npx vectorvesper init
Step 2. Add ComponentInstalls source & assets

Downloads and registers the component in your project:

npx vectorvesper add magnetic-sand
Step 3. Required DependenciesAuto-installed with CLI

If installing manually or managing your package lock:

npm install three @react-three/fiber
Packages:three@react-three/fiber

Usage

Import

import SandCursor from "@/components/vv/magnetic-sand/MagneticSand";

Usage

<SandCursor />

WebGL — render it on the client only

On Next.js, load it with ssr: false from a Client Component, as below. Vite apps need nothing extra.

"use client";

import dynamic from "next/dynamic";

const SandCursor = dynamic(
  () => import("@/components/vv/magnetic-sand/MagneticSand"),
  { ssr: false }
);

Props

A field of fine grains that the cursor drags through, leaving a trail that settles slowly behind it. White ground and black grains by default, so it reads as ink on paper rather than a screen effect.

<MagneticSandVisualizer />

The surface. Fills its container, so give the parent a height.

PropTypeDefaultDescription
backgroundColorstring"#ffffff"Colour of the ground the grains sit on.
color1string"#000000"Colour of an undisturbed grain.
color2string"#00eeff"Colour a grain takes at full disturbance, right under the cursor. It blends back toward color1 as the trail settles.
gridResolutionnumber140.0How many grains across the surface. Higher packs them finer — this is the main control over how the field reads.
particleSizenumber0.0Extra size added to each grain. Zero means the grain fills its cell exactly; raise it to make them overlap and clump.
trailSpeednumber0.05How quickly the field settles back after the cursor passes. Lower leaves the trail standing for longer.
glowIntensitynumber0.3Bloom around disturbed grains. 0 keeps them flat.
classNamestring""Applied to the wrapper around the canvas.
styleReact.CSSProperties{}Merged onto the wrapper. It already fills its parent, so use this for position or z-index rather than size.

Examples

As a section background
import MagneticSandVisualizer from "@/components/vv/magnetic-sand/MagneticSand";

<section className="relative h-screen">
  <div className="absolute inset-0">
    <MagneticSandVisualizer />
  </div>
  <h1 className="relative z-10">Drag across the sand</h1>
</section>
Dark, coarse and slow to settle
<MagneticSandVisualizer
  backgroundColor="#050508"
  color1="#1a1a22"
  color2="#7EACB5"
  gridResolution={70}
  particleSize={0.4}
  trailSpeed={0.015}
  glowIntensity={0.6}
/>
The field only moves in response to the cursor — it is still when nothing is happening, so there is no ambient motion to suppress. It has no explicit reduced-motion state.
The whole field is one GPU pass, so gridResolution is cheap to raise. It is the clearest control to reach for before anything else.
Client component. It opens a 3D drawing context on mount — import it dynamically with ssr: false if your route renders on the server.

Source

"use client";

/**
 * Magnetic Sand — Vector Vesper
 * https://vectorvesper.dev/components
 *
 * Copyright (c) 2026 Vector Vesper
 * Released under the MIT License. This notice must be retained in copies and
 * substantial portions of the file. https://vectorvesper.dev/license
 */
import React, { useRef, useEffect, useMemo } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";

export interface MagneticSandProps {
  backgroundColor?: string;
  color1?: string;
  color2?: string;
  particleSize?: number;
  gridResolution?: number;
  glowIntensity?: number;
  trailSpeed?: number;
  className?: string;
  style?: React.CSSProperties;
}

const MagneticSandScene: React.FC<MagneticSandProps> = ({
  backgroundColor = "#ffffff",
  color1 = "#000000",
  color2 = "#00eeff",
  particleSize = 0.0,
  gridResolution = 140.0,
  glowIntensity = 0.3,
  trailSpeed = 0.05,
}) => {
  const meshRef = useRef<THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>>(null);
  const { viewport } = useThree();

  const setupRef = useRef<{
    canvas: HTMLCanvasElement;
    ctx: CanvasRenderingContext2D | null;
    heatTexture: THREE.CanvasTexture;
  } | null>(null);

  if (setupRef.current === null && typeof window !== "undefined") {
    const can = document.createElement("canvas");
    can.width = 256;
    can.height = 256;
    const context = can.getContext("2d");
    if (context) {
View on GitHubReport an issue