---
title: Colour Trail
description: A greyscale photo the cursor paints back into colour.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/colour-trail
registry: https://ui.dimaac.com/r/colour-trail.json
---

# Colour Trail

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/colour-trail.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/colour-trail.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/colour-trail.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/colour-trail`

Dependencies:

None: it only needs React 19.

By hand:

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.

## Usage

```tsx
import { ColourTrail } from "@/components/dimaac/colour-trail";

<ColourTrail src={src} />
```

## 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. |

## Credits

- Portrait: Sandra L. Sarango, Unsplash (https://unsplash.com/photos/toM-BH2nOHg), Unsplash License

## Source

### colour-trail.tsx → `components/dimaac/colour-trail.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useState } from "react";
import { ColourTrail, type ColourTrailProps } from "@/components/dimaac/colour-trail";

// A UV-paint portrait with a headline over it: the photo is grey until you paint it.
// Photo: Sandra L. Sarango on Unsplash (Unsplash License).

export default function Demo(props: Partial<ColourTrailProps>) {
  // The text rises in once the page is up (only when motion is allowed)
  const [shown, setShown] = useState(false);
  useEffect(() => {
    const id = requestAnimationFrame(() => setShown(true));
    return () => cancelAnimationFrame(id);
  }, []);
  const rise = `motion-safe:transition-[transform,opacity] motion-safe:duration-[1400ms] motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] ${shown ? "translate-y-0 opacity-100" : "motion-safe:translate-y-6 motion-safe:opacity-0"}`;

  return (
    <>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&display=swap" precedence="default" />
      <ColourTrail src="https://ui.dimaac.com/media/portraits/luz.jpg" alt="A woman with glowing ultraviolet paint on her face" {...props} className={`h-full w-full ${props.className ?? ""}`}>
        <div className="pointer-events-none absolute inset-0 grid grid-cols-3 bg-gradient-to-b from-transparent from-55% to-black/60 md:grid-cols-7">
          {Array.from({ length: 7 }, (_, i) => (
            <span key={i} className={`${i > 0 ? "border-l border-white/15" : ""} ${i > 2 ? "hidden md:block" : ""}`} />
          ))}
        </div>

        <main
          className="pointer-events-none absolute inset-7 grid grid-cols-1 grid-rows-[1fr_auto] text-[15px] leading-[1.4] text-white select-none md:inset-x-[84px] md:inset-y-[58px] md:grid-cols-2"
          style={{ fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' }}
        >
          <div className="max-w-[360px] self-end">
            <h1 className={`text-[40px] leading-none uppercase md:text-[56px] ${rise}`} style={{ fontFamily: "Anton, Impact, sans-serif", transitionDelay: "600ms" }}>
              Paint with light
            </h1>
            <p className={`mt-4 text-white/75 ${rise}`} style={{ transitionDelay: "680ms" }}>
              Under ultraviolet every brushstroke wakes up. Here the colour only returns where you touch.
            </p>
          </div>

          <p className={`mt-5 self-end text-left text-[13px] tracking-[0.14em] text-white/75 uppercase md:mt-0 md:justify-self-end md:text-right ${rise}`} style={{ transitionDelay: "760ms" }}>
            Move to paint
          </p>
        </main>
      </ColourTrail>
    </>
  );
}
```

## Agent prompt

```text
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
```
