A pointer-reactive cursor effect for SaaS heroes, AI products, developer tools, creative portfolios, technical landing pages, and interactive editorial sections where the background should respond without stealing the interface.

Dotted Trail turns pointer movement into a visible field of motion.
As the user moves, a soft glow follows the cursor and exposes or intensifies nearby dots. The trail gives the page a sense of responsiveness without replacing the cursor, blocking content, or forcing the visitor into a heavy 3D scene. The effect feels technical, atmospheric, and tactile when it stays behind the main interface.
Use Dotted Trail when a page needs a lightweight interaction layer around a hero, feature section, product intro, or visual story. It works especially well for AI pages, infrastructure brands, developer tools, data products, cyber interfaces, creative studios, and experimental landing pages where a dotted grid already fits the visual language.
The page job is environmental feedback. The user should feel the surface reacting to movement, but the content must remain readable, clickable, and calm. The trail is atmosphere. The page is still the product.
npx hyperiux add dotted-trailimport DottedTrail from '@/components/effects/dotted-trail'
const page = () => {
return (
<>
<DottedTrail/>
</>
)
}
// Built using Hyperiux Vault: https://vault.hyperiux.com
'use client';
import { useEffect, useRef, useSyncExternalStore } from "react";
function usePrefersReducedMotion() {
return useSyncExternalStore((callback) => {
if (typeof window === "undefined")
return () => { };
const mediaQueryList = window.matchMedia("(prefers-reduced-motion: reduce)");
mediaQueryList.addEventListener("change", callback);
return () => mediaQueryList.removeEventListener("change", callback);
}, () => (typeof window === "undefined" ? false : window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false), () => false);
}
// Shader array cap
const TRAIL_LENGTH = 48;
// Active nodes by default
const TRAIL_NODES = 18;
const DOT_SPACING = 34;
const DOT_RADIUS = 1.2;
const GLOW_RADIUS = 300;
const HEAD_RADIUS = 80;
const FOLLOW_EASE = 0.22;
const TRAIL_EASE = 0.45;
// Softens segment weighting
const WEIGHT_SOFTEN = 400.0;
// Newer strokes win
const STACK_BIAS = 8.0;
// Floor for old segments
const STACK_FLOOR = 0.02;
// Tail-to-blob rounding
const JOIN_SOFTNESS = 0.55;
// Soft edge band
const EDGE_INNER = 0.9;
const EDGE_OUTER = 0.95;
// Lifts the interior
const EDGE_GAMMA = 0.75;
// Blend start inside
const RIM_INNER = 2.1;
// Blend end outside
const RIM_OUTER = 1.5;
// Edge pulled to colorA
const EDGE_DESATURATE = 0.3;
// Desaturation band width
const EDGE_DESATURATE_SPAN = 2.1;
// Tail drift to colorB
const TAIL_TILT = 0.18;
// Drift build-up range
const TILT_START = 0.01;
const TILT_END = 0.92;
// Page behind the grid
const BACKGROUND_COLOR = '#ffffff';
// Dot at rest
const DOT_COLOR = '#d1d1d1';
// Dot under the trail
const ACTIVE_DOT_COLOR = '#ffffff';
// Fade when cursor rests
const FADE_ON_IDLE = true;
// Stillness before fading, ms
const IDLE_DELAY = 100;
// Idle fade duration, ms
const IDLE_FADE = 1100;
const VERTEX_SHADER = `#version 300 es
in vec2 aQuad;
void main() {
gl_Position = vec4(aQuad, 0.0, 1.0);
}`;
const FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform vec2 uTrail[${TRAIL_LENGTH}];
uniform float uPresence;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform vec3 uBackground;
uniform vec3 uDotColor;
uniform vec3 uActiveDotColor;
uniform float uSpacing;
uniform float uDotRadius;
uniform float uGlowRadius;
uniform float uHeadRadius;
uniform int uTrailNodes;
uniform float uRimInner;
uniform float uRimOuter;
uniform float uEdgeDesaturate;
uniform float uEdgeDesaturateSpan;
uniform float uTailTilt;
out vec4 outColor;
// Point to segment distance
float distanceToSegment(vec2 point, vec2 a, vec2 b) {
vec2 ab = b - a;
float lengthSquared = dot(ab, ab);
float t = lengthSquared > 0.0001 ? clamp(dot(point - a, ab) / lengthSquared, 0.0, 1.0) : 0.0;
return distance(point, a + ab * t);
}
// Rounded field union
float smoothMin(float a, float b, float softness) {
float h = clamp(0.5 + 0.5 * (b - a) / softness, 0.0, 1.0);
return mix(b, a, h) - softness * h * (1.0 - h);
}
void main() {
vec2 fragment = gl_FragCoord.xy;
// Ribbon distance field
float ribbonField = 1e9;
float tintHead = 0.0;
float tintWeight = 0.0;
int lastSegment = uTrailNodes - 1;
for (int i = 0; i < ${TRAIL_LENGTH - 1}; i++) {
if (i >= lastSegment) break;
float head = float(i) / float(lastSegment);
float segment = distanceToSegment(fragment, uTrail[i], uTrail[i + 1]);
// Fat head, thin tail
float thickness = uHeadRadius * mix(0.32, 1.0, pow(1.0 - head, 1.1));
ribbonField = min(ribbonField, segment - thickness);
float strength = 1.0 - smoothstep(thickness, thickness + uHeadRadius * 0.9, segment);
// Recency weighting
float recency = ${STACK_FLOOR.toFixed(2)} + pow(1.0 - head, ${STACK_BIAS.toFixed(1)});
float weight = strength * strength * recency / (segment * segment + ${WEIGHT_SOFTEN.toFixed(1)});
tintHead += head * weight;
tintWeight += weight;
}
// Position along trail
float blendedHead = tintWeight > 0.0 ? tintHead / tintWeight : 0.0;
float headDistance = distance(fragment, uTrail[0]);
float blobField = headDistance - uHeadRadius;
float bloom = (1.0 - smoothstep(0.0, uGlowRadius, headDistance)) * uPresence;
// Head + tail silhouette
float bodyField = smoothMin(ribbonField, blobField, uHeadRadius * ${JOIN_SOFTNESS.toFixed(2)});
float bodyEdge = 1.0 - smoothstep(-uHeadRadius * ${EDGE_INNER.toFixed(2)}, uHeadRadius * ${EDGE_OUTER.toFixed(2)}, bodyField);
float body = pow(bodyEdge, ${EDGE_GAMMA.toFixed(2)}) * uPresence;
// Flat colour at blob
float headLock = 1.0 - smoothstep(uHeadRadius * 0.10, uHeadRadius * 1.1, headDistance);
float tilt = clamp((blendedHead - ${TILT_START.toFixed(2)}) / ${(TILT_END - TILT_START).toFixed(2)}, 0.0, 1.0);
float gradient = tilt * uTailTilt * (1.0 - headLock);
// Radial core-to-edge ramp
float rim = smoothstep(-uHeadRadius * uRimInner, uHeadRadius * uRimOuter, bodyField);
float blend = clamp(gradient + rim, 0.0, 1.0);
float ink = clamp(body + pow(bloom, 2.0) * 0.8, 0.0, 1.0);
// Edge fades to colorA
float edgeFade = 1.0 - smoothstep(-uHeadRadius * uEdgeDesaturateSpan, uHeadRadius * uEdgeDesaturateSpan, bodyField);
vec3 tint = mix(uColorA, uColorB, blend * mix(uEdgeDesaturate, 1.0, edgeFade));
vec3 color = mix(uBackground, tint, ink);
// Dots punched as holes
vec2 cell = mod(fragment, uSpacing) - uSpacing * 0.5;
float dotSize = uDotRadius * (1.0 + ink * 1.8);
float dot = 1.0 - smoothstep(dotSize - 0.9, dotSize + 0.9, length(cell));
vec3 dotColor = mix(uDotColor, uActiveDotColor, ink);
color = mix(color, dotColor, dot);
outColor = vec4(color, 1.0);
}`;
function hexToRgb(hex) {
const clean = hex.replace('#', '');
const full = clean.length === 3
? clean
.split('')
.map((c) => c + c)
.join('')
: clean;
const value = parseInt(full, 16);
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
}
function compile(gl, type, source) {
const shader = gl.createShader(type);
if (!shader)
return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return null;
}
return shader;
}
const DottedTrail = ({ colorA = '#ff4fd8', colorB = '#8b5cf6', backgroundColor = BACKGROUND_COLOR, dotColor = DOT_COLOR, activeDotColor = ACTIVE_DOT_COLOR, dotSpacing = DOT_SPACING, dotRadius = DOT_RADIUS, glowRadius = GLOW_RADIUS, headRadius = HEAD_RADIUS, followSpeed = FOLLOW_EASE, trailLength = TRAIL_NODES, fadeOnIdle = FADE_ON_IDLE, idleDelay = IDLE_DELAY, idleFade = IDLE_FADE, rimInner = RIM_INNER, rimOuter = RIM_OUTER, edgeDesaturate = EDGE_DESATURATE, edgeDesaturateSpan = EDGE_DESATURATE_SPAN, tailTilt = TAIL_TILT, className = '', children, }) => {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const settingsRef = useRef({
colorA,
colorB,
backgroundColor,
dotColor,
activeDotColor,
dotSpacing,
dotRadius,
glowRadius,
headRadius,
followSpeed,
trailLength,
fadeOnIdle,
idleDelay,
idleFade,
rimInner,
rimOuter,
edgeDesaturate,
edgeDesaturateSpan,
tailTilt,
});
const reducedMotion = usePrefersReducedMotion();
settingsRef.current = {
colorA,
colorB,
backgroundColor,
dotColor,
activeDotColor,
dotSpacing,
dotRadius,
glowRadius,
headRadius,
followSpeed,
trailLength,
fadeOnIdle,
idleDelay,
idleFade,
rimInner,
rimOuter,
edgeDesaturate,
edgeDesaturateSpan,
tailTilt,
};
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container)
return;
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false });
if (!gl)
return;
const vertexShader = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fragmentShader = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
if (!vertexShader || !fragmentShader)
return;
const program = gl.createProgram();
if (!program)
return;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return;
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const quadLocation = gl.getAttribLocation(program, 'aQuad');
gl.enableVertexAttribArray(quadLocation);
gl.vertexAttribPointer(quadLocation, 2, gl.FLOAT, false, 0, 0);
const uniforms = {
resolution: gl.getUniformLocation(program, 'uResolution'),
trail: gl.getUniformLocation(program, 'uTrail'),
presence: gl.getUniformLocation(program, 'uPresence'),
colorA: gl.getUniformLocation(program, 'uColorA'),
colorB: gl.getUniformLocation(program, 'uColorB'),
background: gl.getUniformLocation(program, 'uBackground'),
dotColor: gl.getUniformLocation(program, 'uDotColor'),
activeDotColor: gl.getUniformLocation(program, 'uActiveDotColor'),
spacing: gl.getUniformLocation(program, 'uSpacing'),
dotRadius: gl.getUniformLocation(program, 'uDotRadius'),
glowRadius: gl.getUniformLocation(program, 'uGlowRadius'),
headRadius: gl.getUniformLocation(program, 'uHeadRadius'),
trailNodes: gl.getUniformLocation(program, 'uTrailNodes'),
rimInner: gl.getUniformLocation(program, 'uRimInner'),
rimOuter: gl.getUniformLocation(program, 'uRimOuter'),
edgeDesaturate: gl.getUniformLocation(program, 'uEdgeDesaturate'),
edgeDesaturateSpan: gl.getUniformLocation(program, 'uEdgeDesaturateSpan'),
tailTilt: gl.getUniformLocation(program, 'uTailTilt'),
};
const trail = new Float32Array(TRAIL_LENGTH * 2);
const pointer = { x: 0, y: 0 };
const eased = { x: 0, y: 0 };
let presence = 0;
let targetPresence = 0;
let seeded = false;
let pixelRatio = 1;
let frame = 0;
let lastMoved = 0;
let pointerInside = false;
// Clamped to the shader array size
const activeNodes = () => Math.max(2, Math.min(Math.round(settingsRef.current.trailLength), TRAIL_LENGTH));
// Live props each frame
const pushUniforms = () => {
const settings = settingsRef.current;
gl.uniform3fv(uniforms.colorA, hexToRgb(settings.colorA));
gl.uniform3fv(uniforms.colorB, hexToRgb(settings.colorB));
gl.uniform3fv(uniforms.background, hexToRgb(settings.backgroundColor));
gl.uniform3fv(uniforms.dotColor, hexToRgb(settings.dotColor));
gl.uniform3fv(uniforms.activeDotColor, hexToRgb(settings.activeDotColor));
gl.uniform1f(uniforms.spacing, settings.dotSpacing * pixelRatio);
gl.uniform1f(uniforms.dotRadius, settings.dotRadius * pixelRatio);
gl.uniform1f(uniforms.glowRadius, settings.glowRadius * pixelRatio);
gl.uniform1f(uniforms.headRadius, settings.headRadius * pixelRatio);
gl.uniform1i(uniforms.trailNodes, activeNodes());
gl.uniform1f(uniforms.rimInner, settings.rimInner);
gl.uniform1f(uniforms.rimOuter, settings.rimOuter);
gl.uniform1f(uniforms.edgeDesaturate, settings.edgeDesaturate);
gl.uniform1f(uniforms.edgeDesaturateSpan, settings.edgeDesaturateSpan);
gl.uniform1f(uniforms.tailTilt, settings.tailTilt);
};
const resize = () => {
pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const { width, height } = container.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(width * pixelRatio));
canvas.height = Math.max(1, Math.round(height * pixelRatio));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(uniforms.resolution, canvas.width, canvas.height);
};
const seed = (x, y) => {
for (let i = 0; i < TRAIL_LENGTH; i++) {
trail[i * 2] = x;
trail[i * 2 + 1] = y;
}
eased.x = x;
eased.y = y;
seeded = true;
};
const handlePointerMove = (event) => {
const rect = container.getBoundingClientRect();
// Flip Y for gl_FragCoord
const x = (event.clientX - rect.left) * pixelRatio;
const y = (rect.bottom - event.clientY) * pixelRatio;
// Only a real change counts as movement
if (x !== pointer.x || y !== pointer.y)
lastMoved = performance.now();
pointer.x = x;
pointer.y = y;
pointerInside = true;
targetPresence = 1;
if (!seeded)
seed(pointer.x, pointer.y);
};
const handlePointerLeave = () => {
pointerInside = false;
targetPresence = 0;
};
const render = () => {
frame = requestAnimationFrame(render);
const settings = settingsRef.current;
const followEase = Math.min(Math.max(settings.followSpeed, 0.02), 1);
// Ramped fade once still
let idleFade = 0.0;
if (settings.fadeOnIdle && pointerInside && lastMoved > 0) {
const still = performance.now() - lastMoved - Math.max(0, settings.idleDelay);
const span = Math.max(1, settings.idleFade);
const x = Math.min(Math.max(still / span, 0), 1);
// Smootherstep, soft both ends
idleFade = x * x * x * (x * (x * 6 - 15) + 10);
}
presence += (targetPresence - presence) * 0.08;
presence = Math.min(presence, 1 - idleFade);
eased.x += (pointer.x - eased.x) * followEase;
eased.y += (pointer.y - eased.y) * followEase;
trail[0] = eased.x;
trail[1] = eased.y;
const nodes = activeNodes();
for (let i = 1; i < nodes; i++) {
const index = i * 2;
const previous = (i - 1) * 2;
trail[index] += (trail[previous] - trail[index]) * TRAIL_EASE;
trail[index + 1] += (trail[previous + 1] - trail[index + 1]) * TRAIL_EASE;
}
// Park dormant nodes on the tail tip
const tipX = trail[(nodes - 1) * 2];
const tipY = trail[(nodes - 1) * 2 + 1];
for (let i = nodes; i < TRAIL_LENGTH; i++) {
trail[i * 2] = tipX;
trail[i * 2 + 1] = tipY;
}
gl.uniform2fv(uniforms.trail, trail);
gl.uniform1f(uniforms.presence, presence);
pushUniforms();
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const observer = new ResizeObserver(resize);
observer.observe(container);
resize();
if (reducedMotion) {
// Static dot field, one draw
gl.uniform2fv(uniforms.trail, trail);
gl.uniform1f(uniforms.presence, 0);
pushUniforms();
gl.drawArrays(gl.TRIANGLES, 0, 3);
return () => {
observer.disconnect();
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
gl.deleteBuffer(buffer);
};
}
window.addEventListener('pointermove', handlePointerMove, { passive: true });
container.addEventListener('pointerleave', handlePointerLeave);
frame = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
window.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerleave', handlePointerLeave);
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
gl.deleteBuffer(buffer);
};
}, [reducedMotion]);
return (<div ref={containerRef}
// Runtime colour, so it cannot be a class
style={{ backgroundColor }} className={`relative h-screen w-full overflow-hidden ${className}`}>
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full"/>
<div className="pointer-events-none relative z-10 h-full w-full">{children}</div>
</div>);
};
export default DottedTrail;
A developer-tool homepage can use Dotted Trail in the hero background. The pointer creates a soft glowing path through a dotted grid while the headline, CTA, and product message remain still and readable.
The outcome is technical atmosphere. The page feels reactive and engineered without needing a heavy WebGL hero or distracting animation loop.
An AI product page can use the same effect around a feature intro, making the surface feel computational while keeping the actual value proposition clear.
• SaaS, AI, developer-tool, cyber, and infrastructure landing pages.
• Hero backgrounds where interaction should feel technical but lightweight.
• Product intros that need ambient motion around a stable headline.
• Creative portfolio sections where pointer movement can add tactility.
• Editorial or campaign pages where a dotted field supports the visual system.
• Dark or light technical layouts where a glow trail can create controlled depth.
Not for dense dashboards, checkout flows, forms, documentation, legal pages, pricing tables, or sections where users need quiet utility.
Not for mobile-first experiences that cannot use pointer proximity meaningfully.
Not for pages already overloaded with particles, shaders, video backgrounds, and scroll effects. A cursor trail should add signal, not join the motion committee and start voting against readability.
Keep the dot field lightweight, limit blur size, throttle pointer work with requestAnimationFrame , and avoid React state updates on every pointer move. Store fast-changing pointer values in refs where possible.
Pause or disable the effect when the section is offscreen. Disable or simplify it on touch devices. If using Canvas, cap device pixel ratio and avoid redrawing more than necessary.
The effect should feel immediate without making the browser work like it is rendering a nebula for every mouse move.
Dotted Trail is decorative. It should not communicate essential information, hide text, or interfere with interaction.
On touch devices, disable the pointer trail, show a static dotted field, or use a very subtle non-interactive background. For reduced motion, remove the moving trail and render a static dot texture or calm background state.
Maintain sufficient contrast between text and the glowing layer. The trail should never pass over copy in a way that reduces readability.
• Putting the trail layer above clickable elements.
• Making the glow too bright behind text.
• Updating React state on every pointer move.
• Using too many dots or excessive blur.
• Forgetting to disable the effect on touch devices.
• Treating the dotted trail as meaningful content instead of decoration.
• Running the effect continuously when the section is not visible.
| Prop | Type | Default | Description |
|---|---|---|---|
colorA | color | #ff4fd8 | Core color at the cursor. |
colorB | color | #8b5cf6 | Outer color the blend runs toward. |
backgroundColor | color | #ffffff | Page color behind the grid. |
dotColor | color | #d1d1d1 | Dot color at rest. |
activeDotColor | color | #ffffff | Dot color under the trail. |
headRadius | number | 80 | Radius of the blob at the cursor. |
glowRadius | number | 300 | Reach of the soft glow around the cursor. |
followSpeed | number | 0.22 | How quickly the trail chases the cursor. |
trailLength | number | 18 | How many nodes the tail trails behind. |
fadeOnIdle | boolean | true | Fade the cursor out when it rests. |
idleDelay | number | 100 | Stillness before the fade starts, ms. |
idleFade | number | 1100 | How long the fade takes, ms. |
rimInner | number | 2.1 | Where the blend starts, inside the shape. |
rimOuter | number | 1.5 | Where the blend finishes, past the edge. |
edgeDesaturate | number | 0.3 | Pulls the fading edge back toward colorA. |
edgeDesaturateSpan | number | 2.1 | Band width that desaturation acts over. |
tailTilt | number | 0.18 | Extra drift toward colorB along the tail. |
dotSpacing | number | 34 | Distance between dots in the grid. |
dotRadius | number | 1.2 | Radius of an idle dot. |
Dotted Trail is a React cursor effect where pointer movement creates a soft glowing trail across a dotted field.
Use it for hero backgrounds, product intros, technical landing pages, creative portfolios, and brand sections where the surface should respond subtly to pointer movement.
No. It is a decorative pointer-reactive layer. The native cursor should remain usable, and the effect should not block normal interaction.
It must preserve native click meaning, visible focus, hover states, and modal layering.
Not always. It can be built with DOM and CSS gradients, masks, or pseudo-elements. A Canvas version may be used when the dot field or trail needs more procedural control. Confirm the shipped implementation before assuming the rendering method.
Reduced motion should remove the moving trail, pointer-follow glow, and animated fading path. Show a static dotted surface or no effect.
Need a custom effect? Tell us what to create.