Colour Trail
A greyscale photo the cursor paints back into colour.
Customize
<ColourTrail src={src} />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
// A greyscale photo that the cursor paints back into colour. Two small WebGL1 shaders:
// one keeps a "trail" texture (where the brush has been, fading a little every step), the
// other draws the photo in grey and mixes the colour back in wherever the trail is. Noise wobbles
// the edge of the paint, and a glow mask lets only the bright, colourful pixels take colour.
export type ColourTrailProps = {
/** The photo to paint. Remote images need CORS (WebGL reads their pixels). */
src: string;
/** Describes the photo for screen readers. Empty means the photo is decoration. */
alt?: string;
/** Radius of the brush, as a share of the height. */
brushSize?: number;
/** How fast the paint fades, per step (60 steps a second). 0 keeps it forever. */
fade?: number;
/** How much the paint's edge wobbles. 0 is a clean circle. */
wobble?: number;
/** How much paint a stroke lays down at a given speed. */
intensity?: number;
/** Extra classes for the outer box. Give it a size (it fills it). */
className?: string;
/** Content shown over the photo. The brush keeps painting under it. */
children?: ReactNode;
};
const vertexShader = `
attribute vec2 position;
varying vec2 uv;
void main() {
uv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
// Last step's trail, a little faded, plus a soft line from the brush's last spot to its new one
const trailShader = `
precision highp float;
varying vec2 uv;
uniform sampler2D trail;
uniform vec2 from;
uniform vec2 to;
uniform float aspect;
uniform float size;
uniform float strength;
uniform float fade;
void main() {
float paint = texture2D(trail, uv).r - fade;
vec2 point = (uv - from) * vec2(aspect, 1.0);
vec2 line = (to - from) * vec2(aspect, 1.0);
float along = clamp(dot(point, line) / max(dot(line, line), 0.00001), 0.0, 1.0);
float stroke = 1.0 - smoothstep(0.0, size, length(point - line * along));
gl_FragColor = vec4(paint + stroke * strength, 0.0, 0.0, 1.0);
}`;
// The photo in grey, with its colour mixed back in where the (wobbled) trail is
const photoShader = `
precision highp float;
varying vec2 uv;
uniform sampler2D photo;
uniform sampler2D trail;
uniform vec2 scale;
uniform vec2 offset;
uniform float aspect;
uniform float time;
uniform float wobble;
float random(vec2 point) {
return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
}
float noise(vec2 point) {
vec2 cell = floor(point);
vec2 blend = smoothstep(0.0, 1.0, fract(point));
float a = random(cell);
float b = random(cell + vec2(1.0, 0.0));
float c = random(cell + vec2(0.0, 1.0));
float d = random(cell + vec2(1.0, 1.0));
return mix(mix(a, b, blend.x), mix(c, d, blend.x), blend.y);
}
void main() {
vec3 color = texture2D(photo, uv * scale + offset).rgb;
vec3 gray = vec3(dot(color, vec3(0.299, 0.587, 0.114)));
vec2 point = uv * vec2(aspect, 1.0) * 6.0;
vec2 shift = vec2(noise(point + time * 0.4), noise(point - time * 0.4 + 9.0)) - 0.5;
float reveal = smoothstep(0.4, 0.6, texture2D(trail, uv + shift * wobble).r);
float glow = smoothstep(0.03, 0.12, max(color.g, color.b));
gl_FragColor = vec4(mix(gray, color, reveal * glow), 1.0);
}`;
// The path the brush paints by itself once on touch screens, so people see what it does
const hintPath = [
[0.2, 0.6],
[0.4, 0.7],
[0.6, 0.66],
[0.8, 0.62],
[0.88, 0.5],
] as const;
function pointOnHint(progress: number): [number, number] {
const eased = -(Math.cos(Math.PI * progress) - 1) / 2;
const segments = hintPath.length - 1;
const index = Math.min(Math.floor(eased * segments), segments - 1);
const local = eased * segments - index;
const [ax, ay] = hintPath[index]!;
const [bx, by] = hintPath[index + 1]!;
return [ax + (bx - ax) * local, ay + (by - ay) * local];
}
// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
type Trail = { texture: WebGLTexture; framebuffer: WebGLFramebuffer; width: number; height: number };
export function ColourTrail({ src, alt = "", brushSize = 0.13, fade = 0.01, wobble = 0.06, intensity = 1, className = "", children }: ColourTrailProps) {
const boxRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const settings = useRef({ brushSize, fade, wobble, intensity });
const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
// The src that failed (no WebGL, or the image won't load); a new src tries again
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const failed = failedSrc === src;
const [ready, setReady] = useState(false);
// The render loop reads these every frame, so changing a prop never rebuilds WebGL
useEffect(() => {
settings.current = { brushSize, fade, wobble, intensity };
}, [brushSize, fade, wobble, intensity]);
useEffect(() => {
const box = boxRef.current;
const host = hostRef.current;
if (!box || !host || reducedMotion || failed) return;
// A fresh canvas every time: a canvas whose context was released can't make a new one
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block";
host.appendChild(canvas);
const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false });
if (!gl) {
canvas.remove();
setFailedSrc(src);
return;
}
const context = gl;
let alive = true;
let frame = 0;
let visible = true;
let trails: Trail[] = [];
let trailProgram: WebGLProgram | null = null;
let photoProgram: WebGLProgram | null = null;
let photoTexture: WebGLTexture | null = null;
let buffer: WebGLBuffer | null = null;
let width = 1;
let height = 1;
let lastTime = performance.now();
let leftover = 0;
const pointer = { x: 0.5, y: 0.5 };
const brush = { x: 0.5, y: 0.5 };
let brushPlaced = false;
let touched = false;
let hintStart = -1;
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.src = src;
function createProgram(fragmentShader: string) {
const program = context.createProgram()!;
for (const [type, source] of [
[context.VERTEX_SHADER, vertexShader],
[context.FRAGMENT_SHADER, fragmentShader],
] as const) {
const shader = context.createShader(type)!;
context.shaderSource(shader, source);
context.compileShader(shader);
context.attachShader(program, shader);
context.deleteShader(shader);
}
context.bindAttribLocation(program, 0, "position");
context.linkProgram(program);
return program;
}
function createTexture(w: number, h: number, source?: HTMLImageElement) {
const texture = context.createTexture()!;
context.bindTexture(context.TEXTURE_2D, texture);
if (source) context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, context.RGBA, context.UNSIGNED_BYTE, source);
else context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, w, h, 0, context.RGBA, context.UNSIGNED_BYTE, null);
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.LINEAR);
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.LINEAR);
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE);
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE);
return texture;
}
function createTrail(w: number, h: number): Trail {
const texture = createTexture(w, h);
const framebuffer = context.createFramebuffer()!;
context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT0, context.TEXTURE_2D, texture, 0);
return { texture, framebuffer, width: w, height: h };
}
function deleteTrails() {
for (const trail of trails) {
context.deleteTexture(trail.texture);
context.deleteFramebuffer(trail.framebuffer);
}
trails = [];
}
function draw(program: WebGLProgram, uniforms: Record<string, number | number[]>, textures: Record<string, WebGLTexture>, target?: Trail) {
context.useProgram(program);
for (const [name, value] of Object.entries(uniforms)) {
const location = context.getUniformLocation(program, name);
if (Array.isArray(value)) context.uniform2fv(location, value);
else context.uniform1f(location, value);
}
Object.entries(textures).forEach(([name, texture], unit) => {
context.activeTexture(context.TEXTURE0 + unit);
context.bindTexture(context.TEXTURE_2D, texture);
context.uniform1i(context.getUniformLocation(program, name), unit);
});
context.bindFramebuffer(context.FRAMEBUFFER, target ? target.framebuffer : null);
context.viewport(0, 0, target ? target.width : canvas.width, target ? target.height : canvas.height);
context.drawArrays(context.TRIANGLES, 0, 3);
}
// The canvas matches the box; the trail runs at half size (it's soft anyway)
function resize() {
width = Math.max(1, box!.clientWidth);
height = Math.max(1, box!.clientHeight);
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
if (!trailProgram) return;
deleteTrails();
const w = Math.max(1, Math.round(width / 2));
const h = Math.max(1, Math.round(height / 2));
trails = [createTrail(w, h), createTrail(w, h)];
}
// object-fit: cover for the photo
function cover() {
const boxAspect = width / height;
const photoAspect = image.width / image.height;
const scale = boxAspect > photoAspect ? [1, photoAspect / boxAspect] : [boxAspect / photoAspect, 1];
return { scale, offset: [(1 - scale[0]!) / 2, (1 - scale[1]!) / 2] };
}
// One fixed step: the brush eases toward the pointer and paints the line it travelled
function paintTrail() {
const from = [brush.x, brush.y];
brush.x += (pointer.x - brush.x) * 0.25;
brush.y += (pointer.y - brush.y) * 0.25;
const speed = Math.hypot(brush.x - from[0]!, brush.y - from[1]!);
const { brushSize: size, fade: fading, intensity: amount } = settings.current;
draw(
trailProgram!,
{ from, to: [brush.x, brush.y], aspect: width / height, size, strength: Math.min(speed * 60 * amount, 1), fade: fading },
{ trail: trails[0]!.texture },
trails[1],
);
trails.reverse();
}
function render(now: number) {
if (!alive) return;
frame = requestAnimationFrame(render);
if (!visible) {
lastTime = now;
return;
}
// The brush on touch screens paints its hint path until the first touch
if (hintStart > 0 && !touched && now >= hintStart) {
const progress = Math.min((now - hintStart) / 2000, 1);
const [x, y] = pointOnHint(progress);
pointer.x = x;
pointer.y = y;
if (!brushPlaced) {
brush.x = x;
brush.y = y;
brushPlaced = true;
}
if (progress === 1) hintStart = -1;
}
// 60 trail steps a second whatever the screen's refresh rate, so the look is the same at 120 Hz
leftover = Math.min(leftover + now - lastTime, 100);
lastTime = now;
while (leftover >= 1000 / 60) {
leftover -= 1000 / 60;
paintTrail();
}
const { scale, offset } = cover();
draw(photoProgram!, { scale, offset, aspect: width / height, time: now / 1000, wobble: settings.current.wobble }, { photo: photoTexture!, trail: trails[0]!.texture });
}
function place(event: PointerEvent) {
const rect = box!.getBoundingClientRect();
pointer.x = (event.clientX - rect.left) / rect.width;
pointer.y = 1 - (event.clientY - rect.top) / rect.height;
if (!brushPlaced) {
brush.x = pointer.x;
brush.y = pointer.y;
brushPlaced = true;
}
}
function onPointerMove(event: PointerEvent) {
if (!event.isPrimary) return;
touched = true;
place(event);
}
// A new touch starts a new stroke instead of a line from the last spot
function onPointerDown(event: PointerEvent) {
if (!event.isPrimary) return;
touched = true;
brushPlaced = false;
place(event);
}
function onPointerLeave() {
brushPlaced = false;
}
function onContextLost(event: Event) {
event.preventDefault();
cancelAnimationFrame(frame);
if (alive) setFailedSrc(src);
}
const resizeObserver = new ResizeObserver(resize);
// Nothing to draw while the box is off screen
const visibility = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true;
});
function start() {
if (!alive) return;
trailProgram = createProgram(trailShader);
photoProgram = createProgram(photoShader);
buffer = context.createBuffer();
context.bindBuffer(context.ARRAY_BUFFER, buffer);
context.bufferData(context.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), context.STATIC_DRAW);
context.enableVertexAttribArray(0);
context.vertexAttribPointer(0, 2, context.FLOAT, false, 0, 0);
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, true);
photoTexture = createTexture(image.width, image.height, image);
resize();
resizeObserver.observe(box!);
visibility.observe(box!);
box!.addEventListener("pointermove", onPointerMove);
box!.addEventListener("pointerdown", onPointerDown);
box!.addEventListener("pointerleave", onPointerLeave);
canvas.addEventListener("webglcontextlost", onContextLost);
if (window.matchMedia("(hover: none)").matches) hintStart = performance.now() + 1200;
lastTime = performance.now();
frame = requestAnimationFrame(render);
setReady(true);
}
image
.decode()
.then(start)
.catch(() => alive && setFailedSrc(src));
return () => {
alive = false;
cancelAnimationFrame(frame);
resizeObserver.disconnect();
visibility.disconnect();
box.removeEventListener("pointermove", onPointerMove);
box.removeEventListener("pointerdown", onPointerDown);
box.removeEventListener("pointerleave", onPointerLeave);
canvas.removeEventListener("webglcontextlost", onContextLost);
image.src = "";
deleteTrails();
if (photoTexture) context.deleteTexture(photoTexture);
if (trailProgram) context.deleteProgram(trailProgram);
if (photoProgram) context.deleteProgram(photoProgram);
if (buffer) context.deleteBuffer(buffer);
// Hand the context back now: browsers only allow a few at a time
context.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
setReady(false);
};
}, [src, reducedMotion, failed]);
// Reduced motion, no WebGL or an image that won't load: the photo in full colour, still
const still = reducedMotion || failed;
return (
<div ref={boxRef} className={`relative overflow-hidden bg-neutral-950 ${className}`} style={{ touchAction: "pan-y" }}>
{still ? (
<img src={src} alt={alt} className="absolute inset-0 h-full w-full object-cover" draggable={false} />
) : (
<div
ref={hostRef}
role={alt ? "img" : undefined}
aria-label={alt || undefined}
className="absolute inset-0"
style={{ opacity: ready ? 1 : 0, transform: ready ? "none" : "scale(1.06)", transition: "opacity 2.4s cubic-bezier(0.2, 0.7, 0.2, 1), transform 2.4s cubic-bezier(0.2, 0.7, 0.2, 1)" }}
/>
)}
{/* Pointer events from here bubble up to the box, so the brush keeps painting under the text */}
{children ? <div className="relative h-full w-full">{children}</div> : null}
</div>
);
}
The component. Installs to components/dimaac/colour-trail.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/colour-trail.jsonIt adds components/dimaac/colour-trail.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/colour-trail.
By hand
# Nothing to install: it only needs React- It needs nothing but React 19.
- Copy the component file below into components/dimaac/colour-trail.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { ColourTrail } from "@/components/dimaac/colour-trail" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
src (required) | string | - | The photo to paint. Remote images need CORS (WebGL reads their pixels). |
alt | string | "" | Describes the photo for screen readers. Empty means the photo is decoration. |
brushSize | number | 0.13 | Radius of the brush, as a share of the height. |
fade | number | 0.01 | How fast the paint fades, per step (60 steps a second). 0 keeps it forever. |
wobble | number | 0.06 | How much the paint's edge wobbles. 0 is a clean circle. |
intensity | number | 1 | How much paint a stroke lays down at a given speed. |
className | string | "" | Extra classes for the outer box. Give it a size (it fills it). |
children | ReactNode | - | Content shown over the photo. The brush keeps painting under it. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the Colour Trail component (<ColourTrail />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/colour-trail
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/colour-trail.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/colour-trail.json
Then use it like this:
import { ColourTrail } from "@/components/dimaac/colour-trail";
<ColourTrail src={src} />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/colour-trail.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- colour-trail.md
- This page as Markdown: install, props and the full source
- colour-trail.json
- The shadcn registry item
About
A photo shown in greyscale until the cursor paints over it. The paint has a soft, wobbling edge, fades on its own and only lights up the bright, colourful parts of the picture, so a UV-paint portrait glows where you touch it. Two tiny WebGL shaders, no libraries. It paints at a fixed 60 steps a second on any screen, plays a short stroke by itself on touch screens, and shows the photo in full colour, still, when the visitor prefers reduced motion.
- Installs to
components/dimaac/colour-trail.tsx- Uses
- React only
- Tags
- webgl, shader, cursor, image, hero
Portrait by Sandra L. Sarango on Unsplash (Unsplash License).