Return

Ink Drift Canvas

A stroke that lets go from where it began.

ink-drift-canvas

Each point has its own lifetime.

Why it feels good: a stroke is not a single object with one opacity. Each sampled point owns a lifetime, so the origin disappears first while the newest ink remains present. That timing detail makes the mark feel alive instead of abruptly removed.

Source

components/interior/ink-drift-canvas.tsx
"use client";

import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const penCursor = (color: string) => {
  const svg = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.4001 18.1612L18.796 10.7653C17.7894 10.3464 16.5972 9.6582 15.4697 8.53068C14.342 7.40298 13.6537 6.21058 13.2348 5.2039L5.83882 12.5999C5.26166 13.1771 4.97307 13.4657 4.7249 13.7838C4.43213 14.1592 4.18114 14.5653 3.97634 14.995C3.80273 15.3593 3.67368 15.7465 3.41556 16.5208L2.05445 20.6042C1.92743 20.9852 2.0266 21.4053 2.31063 21.6894C2.59466 21.9734 3.01478 22.0726 3.39584 21.9456L7.47918 20.5844C8.25351 20.3263 8.6407 20.1973 9.00498 20.0237C9.43469 19.8189 9.84082 19.5679 10.2162 19.2751C10.5343 19.0269 10.823 18.7383 11.4001 18.1612Z" fill="${color}"/><path d="M20.8482 8.71306C22.3839 7.17735 22.3839 4.68748 20.8482 3.15178C19.3125 1.61607 16.8226 1.61607 15.2869 3.15178L14.3999 4.03882C14.4121 4.0755 14.4246 4.11268 14.4377 4.15035C14.7628 5.0875 15.3763 6.31601 16.5303 7.47002C17.6843 8.62403 18.9128 9.23749 19.85 9.56262C19.8875 9.57563 19.9245 9.58817 19.961 9.60026L20.8482 8.71306Z" fill="${color}"/></svg>`;
  return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 2 16, crosshair`;
};

type InkPoint = {
  color: string;
  createdAt: number;
  pressure: number;
  stroke: number;
  x: number;
  y: number;
};

export type InkDriftCanvasProps = {
  className?: string;
  color?: string;
  cursorColor?: string;
  duration?: number;
  lineWidth?: number;
};

/**
 * A pressure-aware drawing surface where each part of a stroke fades according
 * to when it was drawn. The beginning disappears before the tip reaches rest.
 */
export function InkDriftCanvas({
  className,
  color = "#171717",
  cursorColor = "#171717",
  duration = 1450,
  lineWidth = 1.35,
}: InkDriftCanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const pointsRef = useRef<InkPoint[]>([]);
  const frameRef = useRef<number | null>(null);
  const strokeRef = useRef(0);
  const activePointerRef = useRef<number | null>(null);
  const reducedMotion = useReducedMotion() ?? false;

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    if (!canvas || !context) return;

    let width = 0;
    let height = 0;
    let dpr = 1;
    const fadeDuration = reducedMotion ? Number.POSITIVE_INFINITY : duration;

    const paint = (now = performance.now()) => {
      frameRef.current = null;
      context.setTransform(dpr, 0, 0, dpr, 0, 0);
      context.clearRect(0, 0, width, height);
      context.lineCap = "round";
      context.lineJoin = "round";

      const visible = pointsRef.current.filter((point) => now - point.createdAt < fadeDuration);
      pointsRef.current = visible;

      for (let index = 0; index < visible.length; index += 1) {
        const point = visible[index];
        const previous = visible[index - 1];

        const alpha = Math.max(0, 1 - (now - point.createdAt) / fadeDuration);
        context.globalAlpha = alpha;
        context.strokeStyle = point.color;
        context.lineWidth = lineWidth * (0.62 + point.pressure * 0.92);

        if (!previous || previous.stroke !== point.stroke) {
          context.fillStyle = point.color;
          context.beginPath();
          context.arc(point.x, point.y, context.lineWidth / 2, 0, Math.PI * 2);
          context.fill();
          continue;
        }

        context.beginPath();
        context.moveTo(previous.x, previous.y);
        context.lineTo(point.x, point.y);
        context.stroke();
      }

      context.globalAlpha = 1;
      if (visible.length > 0 && !reducedMotion) {
        frameRef.current = window.requestAnimationFrame(paint);
      }
    };

    const requestPaint = () => {
      if (frameRef.current === null) frameRef.current = window.requestAnimationFrame(paint);
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      width = Math.max(1, rect.width);
      height = Math.max(1, rect.height);
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
      requestPaint();
    };

    const pointFromEvent = (event: PointerEvent): InkPoint => {
      const rect = canvas.getBoundingClientRect();
      return {
        color,
        createdAt: performance.now(),
        pressure: event.pressure > 0 ? event.pressure : 0.5,
        stroke: strokeRef.current,
        x: event.clientX - rect.left,
        y: event.clientY - rect.top,
      };
    };

    const addPoint = (event: PointerEvent, immediately = false) => {
      pointsRef.current.push(pointFromEvent(event));
      if (immediately) paint();
      else requestPaint();
    };

    const onPointerDown = (event: PointerEvent) => {
      activePointerRef.current = event.pointerId;
      strokeRef.current += 1;
      canvas.setPointerCapture(event.pointerId);
      addPoint(event, true);
    };

    const onPointerMove = (event: PointerEvent) => {
      if (activePointerRef.current !== event.pointerId) return;
      const events = event.getCoalescedEvents?.() ?? [event];
      events.forEach((sample) => addPoint(sample));
    };

    const endStroke = (event: PointerEvent) => {
      if (activePointerRef.current !== event.pointerId) return;
      activePointerRef.current = null;
      if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
    };

    const observer = new ResizeObserver(resize);
    observer.observe(canvas);
    canvas.addEventListener("pointerdown", onPointerDown);
    canvas.addEventListener("pointermove", onPointerMove);
    canvas.addEventListener("pointerup", endStroke);
    canvas.addEventListener("pointercancel", endStroke);
    resize();

    return () => {
      observer.disconnect();
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", endStroke);
      canvas.removeEventListener("pointercancel", endStroke);
      if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current);
    };
  }, [color, duration, lineWidth, reducedMotion]);

  return <canvas ref={canvasRef} aria-label="Draw on the fading ink canvas" style={{ cursor: penCursor(cursorColor) }} className={cn("block size-full touch-none", className)} />;
}

Usage

signature-pad.tsx
"use client";

import { InkDriftCanvas } from "@/components/interior/ink-drift-canvas";

export function SignaturePad() {
  return (
    <div className="h-80 overflow-hidden rounded-2xl border bg-stone-50">
      <InkDriftCanvas
        color="#ef4223"
        cursorColor="#171717"
        duration={1200}
        lineWidth={1.5}
      />
    </div>
  );
}

Props

color"#171717"
string

Stroke colour. Update it without changing the canvas layout.

cursorColor"#171717"
string

Colour of the native pen cursor. Choose a contrasting value for dark surfaces.

duration1450
number

How long each point remains visible, in milliseconds.

lineWidth1.35
number

Base stroke width in CSS pixels; pen pressure scales from this value.

classNameundefined
string

Classes applied to the canvas. Give its parent an explicit height.

Inspired by the visual canvas language of Rivet. Independently implemented for this experiment.