Ribbon Cursor Trail

The pointer leaves a smooth ribbon behind it that thins out and fades within half a second.

  • Canvas
  • Cursor
  • Pointer
  • Added
Draw with your pointer

AI prompt

Build a React cursor trail on a <canvas> that covers its container. Keep an array of the last pointer points with timestamps (push on pointermove, drop points older than 450ms). Each requestAnimationFrame, clear the canvas and draw the points as connected segments with quadraticCurveTo through midpoints for a smooth line; set each segment’s lineWidth from 1px to 10px and its globalAlpha from 0 to 1 based on its age, so the tail thins and fades. Use lineCap round and the accent colour from a CSS custom property. Only run the loop while points exist. Size the canvas with devicePixelRatio and ResizeObserver, and ignore touch pointers so scrolling on phones is not blocked. Under prefers-reduced-motion, do not draw a trail. Caption: "Draw with your pointer".

Source

Demo.tsx
'use client'

import { useEffect, useRef } from 'react'
import s from './demo.module.css'

const LIFE = 450

export default function CursorTrail({ labels }: { labels: Record<string, string> }) {
  const ref = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const canvas = ref.current!
    const ctx = canvas.getContext('2d')!
    const color = getComputedStyle(canvas).getPropertyValue('--trail').trim()
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
    let points: { x: number; y: number; t: number }[] = []
    let raf = 0
    let w = 0
    let h = 0

    const resize = () => {
      const dpr = window.devicePixelRatio || 1
      w = canvas.clientWidth
      h = canvas.clientHeight
      canvas.width = w * dpr
      canvas.height = h * dpr
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    }

    const draw = () => {
      const now = performance.now()
      points = points.filter((p) => now - p.t < LIFE)
      ctx.clearRect(0, 0, w, h)
      ctx.strokeStyle = color
      ctx.lineCap = 'round'
      for (let i = 1; i < points.length - 1; i++) {
        const life = 1 - (now - points[i].t) / LIFE
        const a = points[i - 1]
        const b = points[i]
        const c = points[i + 1]
        ctx.globalAlpha = life
        ctx.lineWidth = 1 + life * 9
        ctx.beginPath()
        ctx.moveTo((a.x + b.x) / 2, (a.y + b.y) / 2)
        ctx.quadraticCurveTo(b.x, b.y, (b.x + c.x) / 2, (b.y + c.y) / 2)
        ctx.stroke()
      }
      ctx.globalAlpha = 1
      raf = points.length ? requestAnimationFrame(draw) : 0
    }

    const onMove = (e: PointerEvent) => {
      if (reduced || e.pointerType === 'touch') return
      const r = canvas.getBoundingClientRect()
      points.push({ x: e.clientX - r.left, y: e.clientY - r.top, t: performance.now() })
      if (!raf) raf = requestAnimationFrame(draw)
    }

    const ro = new ResizeObserver(resize)
    ro.observe(canvas)
    canvas.addEventListener('pointermove', onMove)
    return () => {
      cancelAnimationFrame(raf)
      ro.disconnect()
      canvas.removeEventListener('pointermove', onMove)
    }
  }, [])

  return (
    <div className={s.wrap}>
      <canvas ref={ref} className={s.canvas} aria-hidden="true" />
      <span className={s.caption}>{labels.caption}</span>
    </div>
  )
}
demo.module.css
.wrap {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}

.canvas {
  --trail: var(--stage-accent);
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  cursor: crosshair;
}

.caption {
  position: relative;
  font: 500 var(--text-xs) / 1 var(--font-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--stage-muted);
  pointer-events: none;
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.