{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"colour-trail","type":"registry:component","title":"Colour Trail","description":"A greyscale photo the cursor paints back into colour.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":[],"files":[{"path":"registry/dimaac/colour-trail/colour-trail.tsx","type":"registry:component","target":"components/dimaac/colour-trail.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from \"react\";\n\n// A greyscale photo that the cursor paints back into colour. Two small WebGL1 shaders:\n// one keeps a \"trail\" texture (where the brush has been, fading a little every step), the\n// other draws the photo in grey and mixes the colour back in wherever the trail is. Noise wobbles\n// the edge of the paint, and a glow mask lets only the bright, colourful pixels take colour.\n\nexport type ColourTrailProps = {\n  /** The photo to paint. Remote images need CORS (WebGL reads their pixels). */\n  src: string;\n  /** Describes the photo for screen readers. Empty means the photo is decoration. */\n  alt?: string;\n  /** Radius of the brush, as a share of the height. */\n  brushSize?: number;\n  /** How fast the paint fades, per step (60 steps a second). 0 keeps it forever. */\n  fade?: number;\n  /** How much the paint's edge wobbles. 0 is a clean circle. */\n  wobble?: number;\n  /** How much paint a stroke lays down at a given speed. */\n  intensity?: number;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n  /** Content shown over the photo. The brush keeps painting under it. */\n  children?: ReactNode;\n};\n\nconst vertexShader = `\nattribute vec2 position;\nvarying vec2 uv;\nvoid main() {\n  uv = position * 0.5 + 0.5;\n  gl_Position = vec4(position, 0.0, 1.0);\n}`;\n\n// Last step's trail, a little faded, plus a soft line from the brush's last spot to its new one\nconst trailShader = `\nprecision highp float;\nvarying vec2 uv;\nuniform sampler2D trail;\nuniform vec2 from;\nuniform vec2 to;\nuniform float aspect;\nuniform float size;\nuniform float strength;\nuniform float fade;\nvoid main() {\n  float paint = texture2D(trail, uv).r - fade;\n  vec2 point = (uv - from) * vec2(aspect, 1.0);\n  vec2 line = (to - from) * vec2(aspect, 1.0);\n  float along = clamp(dot(point, line) / max(dot(line, line), 0.00001), 0.0, 1.0);\n  float stroke = 1.0 - smoothstep(0.0, size, length(point - line * along));\n  gl_FragColor = vec4(paint + stroke * strength, 0.0, 0.0, 1.0);\n}`;\n\n// The photo in grey, with its colour mixed back in where the (wobbled) trail is\nconst photoShader = `\nprecision highp float;\nvarying vec2 uv;\nuniform sampler2D photo;\nuniform sampler2D trail;\nuniform vec2 scale;\nuniform vec2 offset;\nuniform float aspect;\nuniform float time;\nuniform float wobble;\nfloat random(vec2 point) {\n  return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);\n}\nfloat noise(vec2 point) {\n  vec2 cell = floor(point);\n  vec2 blend = smoothstep(0.0, 1.0, fract(point));\n  float a = random(cell);\n  float b = random(cell + vec2(1.0, 0.0));\n  float c = random(cell + vec2(0.0, 1.0));\n  float d = random(cell + vec2(1.0, 1.0));\n  return mix(mix(a, b, blend.x), mix(c, d, blend.x), blend.y);\n}\nvoid main() {\n  vec3 color = texture2D(photo, uv * scale + offset).rgb;\n  vec3 gray = vec3(dot(color, vec3(0.299, 0.587, 0.114)));\n  vec2 point = uv * vec2(aspect, 1.0) * 6.0;\n  vec2 shift = vec2(noise(point + time * 0.4), noise(point - time * 0.4 + 9.0)) - 0.5;\n  float reveal = smoothstep(0.4, 0.6, texture2D(trail, uv + shift * wobble).r);\n  float glow = smoothstep(0.03, 0.12, max(color.g, color.b));\n  gl_FragColor = vec4(mix(gray, color, reveal * glow), 1.0);\n}`;\n\n// The path the brush paints by itself once on touch screens, so people see what it does\nconst hintPath = [\n  [0.2, 0.6],\n  [0.4, 0.7],\n  [0.6, 0.66],\n  [0.8, 0.62],\n  [0.88, 0.5],\n] as const;\n\nfunction pointOnHint(progress: number): [number, number] {\n  const eased = -(Math.cos(Math.PI * progress) - 1) / 2;\n  const segments = hintPath.length - 1;\n  const index = Math.min(Math.floor(eased * segments), segments - 1);\n  const local = eased * segments - index;\n  const [ax, ay] = hintPath[index]!;\n  const [bx, by] = hintPath[index + 1]!;\n  return [ax + (bx - ax) * local, ay + (by - ay) * local];\n}\n\n// prefers-reduced-motion, live\nfunction subscribeToMotion(onChange: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nconst prefersReducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\ntype Trail = { texture: WebGLTexture; framebuffer: WebGLFramebuffer; width: number; height: number };\n\nexport function ColourTrail({ src, alt = \"\", brushSize = 0.13, fade = 0.01, wobble = 0.06, intensity = 1, className = \"\", children }: ColourTrailProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const hostRef = useRef<HTMLDivElement>(null);\n  const settings = useRef({ brushSize, fade, wobble, intensity });\n  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);\n  // The src that failed (no WebGL, or the image won't load); a new src tries again\n  const [failedSrc, setFailedSrc] = useState<string | null>(null);\n  const failed = failedSrc === src;\n  const [ready, setReady] = useState(false);\n\n  // The render loop reads these every frame, so changing a prop never rebuilds WebGL\n  useEffect(() => {\n    settings.current = { brushSize, fade, wobble, intensity };\n  }, [brushSize, fade, wobble, intensity]);\n\n  useEffect(() => {\n    const box = boxRef.current;\n    const host = hostRef.current;\n    if (!box || !host || reducedMotion || failed) return;\n\n    // A fresh canvas every time: a canvas whose context was released can't make a new one\n    const canvas = document.createElement(\"canvas\");\n    canvas.setAttribute(\"aria-hidden\", \"true\");\n    canvas.style.cssText = \"position:absolute;inset:0;width:100%;height:100%;display:block\";\n    host.appendChild(canvas);\n\n    const gl = canvas.getContext(\"webgl\", { antialias: false, premultipliedAlpha: false });\n    if (!gl) {\n      canvas.remove();\n      setFailedSrc(src);\n      return;\n    }\n    const context = gl;\n\n    let alive = true;\n    let frame = 0;\n    let visible = true;\n    let trails: Trail[] = [];\n    let trailProgram: WebGLProgram | null = null;\n    let photoProgram: WebGLProgram | null = null;\n    let photoTexture: WebGLTexture | null = null;\n    let buffer: WebGLBuffer | null = null;\n    let width = 1;\n    let height = 1;\n    let lastTime = performance.now();\n    let leftover = 0;\n\n    const pointer = { x: 0.5, y: 0.5 };\n    const brush = { x: 0.5, y: 0.5 };\n    let brushPlaced = false;\n    let touched = false;\n    let hintStart = -1;\n\n    const image = new Image();\n    image.crossOrigin = \"anonymous\";\n    image.decoding = \"async\";\n    image.src = src;\n\n    function createProgram(fragmentShader: string) {\n      const program = context.createProgram()!;\n      for (const [type, source] of [\n        [context.VERTEX_SHADER, vertexShader],\n        [context.FRAGMENT_SHADER, fragmentShader],\n      ] as const) {\n        const shader = context.createShader(type)!;\n        context.shaderSource(shader, source);\n        context.compileShader(shader);\n        context.attachShader(program, shader);\n        context.deleteShader(shader);\n      }\n      context.bindAttribLocation(program, 0, \"position\");\n      context.linkProgram(program);\n      return program;\n    }\n\n    function createTexture(w: number, h: number, source?: HTMLImageElement) {\n      const texture = context.createTexture()!;\n      context.bindTexture(context.TEXTURE_2D, texture);\n      if (source) context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, context.RGBA, context.UNSIGNED_BYTE, source);\n      else context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, w, h, 0, context.RGBA, context.UNSIGNED_BYTE, null);\n      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.LINEAR);\n      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.LINEAR);\n      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE);\n      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE);\n      return texture;\n    }\n\n    function createTrail(w: number, h: number): Trail {\n      const texture = createTexture(w, h);\n      const framebuffer = context.createFramebuffer()!;\n      context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);\n      context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT0, context.TEXTURE_2D, texture, 0);\n      return { texture, framebuffer, width: w, height: h };\n    }\n\n    function deleteTrails() {\n      for (const trail of trails) {\n        context.deleteTexture(trail.texture);\n        context.deleteFramebuffer(trail.framebuffer);\n      }\n      trails = [];\n    }\n\n    function draw(program: WebGLProgram, uniforms: Record<string, number | number[]>, textures: Record<string, WebGLTexture>, target?: Trail) {\n      context.useProgram(program);\n      for (const [name, value] of Object.entries(uniforms)) {\n        const location = context.getUniformLocation(program, name);\n        if (Array.isArray(value)) context.uniform2fv(location, value);\n        else context.uniform1f(location, value);\n      }\n      Object.entries(textures).forEach(([name, texture], unit) => {\n        context.activeTexture(context.TEXTURE0 + unit);\n        context.bindTexture(context.TEXTURE_2D, texture);\n        context.uniform1i(context.getUniformLocation(program, name), unit);\n      });\n      context.bindFramebuffer(context.FRAMEBUFFER, target ? target.framebuffer : null);\n      context.viewport(0, 0, target ? target.width : canvas.width, target ? target.height : canvas.height);\n      context.drawArrays(context.TRIANGLES, 0, 3);\n    }\n\n    // The canvas matches the box; the trail runs at half size (it's soft anyway)\n    function resize() {\n      width = Math.max(1, box!.clientWidth);\n      height = Math.max(1, box!.clientHeight);\n      const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(width * pixelRatio);\n      canvas.height = Math.round(height * pixelRatio);\n      if (!trailProgram) return;\n      deleteTrails();\n      const w = Math.max(1, Math.round(width / 2));\n      const h = Math.max(1, Math.round(height / 2));\n      trails = [createTrail(w, h), createTrail(w, h)];\n    }\n\n    // object-fit: cover for the photo\n    function cover() {\n      const boxAspect = width / height;\n      const photoAspect = image.width / image.height;\n      const scale = boxAspect > photoAspect ? [1, photoAspect / boxAspect] : [boxAspect / photoAspect, 1];\n      return { scale, offset: [(1 - scale[0]!) / 2, (1 - scale[1]!) / 2] };\n    }\n\n    // One fixed step: the brush eases toward the pointer and paints the line it travelled\n    function paintTrail() {\n      const from = [brush.x, brush.y];\n      brush.x += (pointer.x - brush.x) * 0.25;\n      brush.y += (pointer.y - brush.y) * 0.25;\n      const speed = Math.hypot(brush.x - from[0]!, brush.y - from[1]!);\n      const { brushSize: size, fade: fading, intensity: amount } = settings.current;\n\n      draw(\n        trailProgram!,\n        { from, to: [brush.x, brush.y], aspect: width / height, size, strength: Math.min(speed * 60 * amount, 1), fade: fading },\n        { trail: trails[0]!.texture },\n        trails[1],\n      );\n      trails.reverse();\n    }\n\n    function render(now: number) {\n      if (!alive) return;\n      frame = requestAnimationFrame(render);\n      if (!visible) {\n        lastTime = now;\n        return;\n      }\n\n      // The brush on touch screens paints its hint path until the first touch\n      if (hintStart > 0 && !touched && now >= hintStart) {\n        const progress = Math.min((now - hintStart) / 2000, 1);\n        const [x, y] = pointOnHint(progress);\n        pointer.x = x;\n        pointer.y = y;\n        if (!brushPlaced) {\n          brush.x = x;\n          brush.y = y;\n          brushPlaced = true;\n        }\n        if (progress === 1) hintStart = -1;\n      }\n\n      // 60 trail steps a second whatever the screen's refresh rate, so the look is the same at 120 Hz\n      leftover = Math.min(leftover + now - lastTime, 100);\n      lastTime = now;\n      while (leftover >= 1000 / 60) {\n        leftover -= 1000 / 60;\n        paintTrail();\n      }\n\n      const { scale, offset } = cover();\n      draw(photoProgram!, { scale, offset, aspect: width / height, time: now / 1000, wobble: settings.current.wobble }, { photo: photoTexture!, trail: trails[0]!.texture });\n    }\n\n    function place(event: PointerEvent) {\n      const rect = box!.getBoundingClientRect();\n      pointer.x = (event.clientX - rect.left) / rect.width;\n      pointer.y = 1 - (event.clientY - rect.top) / rect.height;\n      if (!brushPlaced) {\n        brush.x = pointer.x;\n        brush.y = pointer.y;\n        brushPlaced = true;\n      }\n    }\n\n    function onPointerMove(event: PointerEvent) {\n      if (!event.isPrimary) return;\n      touched = true;\n      place(event);\n    }\n\n    // A new touch starts a new stroke instead of a line from the last spot\n    function onPointerDown(event: PointerEvent) {\n      if (!event.isPrimary) return;\n      touched = true;\n      brushPlaced = false;\n      place(event);\n    }\n\n    function onPointerLeave() {\n      brushPlaced = false;\n    }\n\n    function onContextLost(event: Event) {\n      event.preventDefault();\n      cancelAnimationFrame(frame);\n      if (alive) setFailedSrc(src);\n    }\n\n    const resizeObserver = new ResizeObserver(resize);\n    // Nothing to draw while the box is off screen\n    const visibility = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true;\n    });\n\n    function start() {\n      if (!alive) return;\n      trailProgram = createProgram(trailShader);\n      photoProgram = createProgram(photoShader);\n      buffer = context.createBuffer();\n      context.bindBuffer(context.ARRAY_BUFFER, buffer);\n      context.bufferData(context.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), context.STATIC_DRAW);\n      context.enableVertexAttribArray(0);\n      context.vertexAttribPointer(0, 2, context.FLOAT, false, 0, 0);\n      context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, true);\n      photoTexture = createTexture(image.width, image.height, image);\n\n      resize();\n      resizeObserver.observe(box!);\n      visibility.observe(box!);\n      box!.addEventListener(\"pointermove\", onPointerMove);\n      box!.addEventListener(\"pointerdown\", onPointerDown);\n      box!.addEventListener(\"pointerleave\", onPointerLeave);\n      canvas.addEventListener(\"webglcontextlost\", onContextLost);\n\n      if (window.matchMedia(\"(hover: none)\").matches) hintStart = performance.now() + 1200;\n      lastTime = performance.now();\n      frame = requestAnimationFrame(render);\n      setReady(true);\n    }\n\n    image\n      .decode()\n      .then(start)\n      .catch(() => alive && setFailedSrc(src));\n\n    return () => {\n      alive = false;\n      cancelAnimationFrame(frame);\n      resizeObserver.disconnect();\n      visibility.disconnect();\n      box.removeEventListener(\"pointermove\", onPointerMove);\n      box.removeEventListener(\"pointerdown\", onPointerDown);\n      box.removeEventListener(\"pointerleave\", onPointerLeave);\n      canvas.removeEventListener(\"webglcontextlost\", onContextLost);\n      image.src = \"\";\n      deleteTrails();\n      if (photoTexture) context.deleteTexture(photoTexture);\n      if (trailProgram) context.deleteProgram(trailProgram);\n      if (photoProgram) context.deleteProgram(photoProgram);\n      if (buffer) context.deleteBuffer(buffer);\n      // Hand the context back now: browsers only allow a few at a time\n      context.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      canvas.remove();\n      setReady(false);\n    };\n  }, [src, reducedMotion, failed]);\n\n  // Reduced motion, no WebGL or an image that won't load: the photo in full colour, still\n  const still = reducedMotion || failed;\n\n  return (\n    <div ref={boxRef} className={`relative overflow-hidden bg-neutral-950 ${className}`} style={{ touchAction: \"pan-y\" }}>\n      {still ? (\n        <img src={src} alt={alt} className=\"absolute inset-0 h-full w-full object-cover\" draggable={false} />\n      ) : (\n        <div\n          ref={hostRef}\n          role={alt ? \"img\" : undefined}\n          aria-label={alt || undefined}\n          className=\"absolute inset-0\"\n          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)\" }}\n        />\n      )}\n      {/* Pointer events from here bubble up to the box, so the brush keeps painting under the text */}\n      {children ? <div className=\"relative h-full w-full\">{children}</div> : null}\n    </div>\n  );\n}\n"}],"categories":["cursor","webgl","shader","image","hero"],"docs":"import { ColourTrail } from \"@/components/dimaac/colour-trail\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/colour-trail","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/colour-trail","credits":[{"by":"Sandra L. Sarango","url":"https://unsplash.com/photos/toM-BH2nOHg","what":"Portrait","source":"Unsplash","licence":"Unsplash License"}]}}