← Components

Colour Trail

A greyscale photo the cursor paints back into colour.

Cursor · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

0.13
0.010
0.060
1.0
Your code
<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.json

It 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
  1. It needs nothing but React 19.
  2. Copy the component file below into components/dimaac/colour-trail.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { ColourTrail } from "@/components/dimaac/colour-trail" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
src (required)string-The photo to paint. Remote images need CORS (WebGL reads their pixels).
altstring""Describes the photo for screen readers. Empty means the photo is decoration.
brushSizenumber0.13Radius of the brush, as a share of the height.
fadenumber0.01How fast the paint fades, per step (60 steps a second). 0 keeps it forever.
wobblenumber0.06How much the paint's edge wobbles. 0 is a clean circle.
intensitynumber1How much paint a stroke lays down at a given speed.
classNamestring""Extra classes for the outer box. Give it a size (it fills it).
childrenReactNode-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.

Prompt
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.txt

Files 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).