# Imprint — Cursor Effect for React

> Letterpress image trail: pointer travel stamps a pile of proofs that land with a settle, a seeded tilt, a registration double and an ink-spread shadow, then fade and desaturate like aging paper. Clicks stack larger proofs, a seeded ghost path keeps printing when idle, and an imperative stamp(x, y) handle; reduced motion rests on a static corner pile.

- Page: https://crucibleui.com/components/cursors/imprint
- Category: [cursors](https://crucibleui.com/components/cursors)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/imprint
```

Registry JSON: https://crucibleui.com/r/imprint.json

Import from `@/components/crucible/imprint`.

## Props (`ImprintProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `React.ReactNode` | — | Content inside the scope. Stays fully interactive; the trail prints behind it. |
| `className?` | `string` | — | — |
| `items?` | `ImprintItem[]` | `a seeded set of soft slate/graphite gradient tiles with short catalog labels` | What gets stamped, cycled in order. |
| `spacing?` | `number` | `90` | Pointer travel (px) between stamps. |
| `size?` | `number` | `140` | Stamp width in px (height is 5:4 of it). Click proofs are 1.45x. |
| `max?` | `number` | `12` | Most stamps on the pile at once; the oldest lift off beyond it. |
| `life?` | `number` | `1600` | How long (ms) a stamp rests before it fades and desaturates away. |
| `rotate?` | `number` | `6` | Max seeded tilt either way, in degrees. |
| `idle?` | `boolean` | `true` | Ghost autoplay along a seeded Lissajous path after 1.5s without a pointer. |
| `seed?` | `number` | `7` | Seed for the ghost path, tilts, registration offsets and default tiles. |
| `paused?` | `boolean` | `false` | Stop stamping (pointer and ghost). Existing stamps finish aging out. |
| `ref?` | `React.Ref<ImprintHandle>` | — | React 19 ref prop — imperative `stamp(x, y)`. |

## Usage example

```tsx
import { Imprint } from "@/components/crucible/imprint";

export default function ImprintDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <Imprint className="absolute inset-0 flex items-center justify-center">
        <div className="relative z-10 flex flex-col items-center gap-3 rounded-2xl bg-neutral-950/55 px-7 py-5 text-center backdrop-blur-sm">
          <p className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">Move your cursor</p>
          <p className="text-sm text-neutral-400">Click to press a larger proof.</p>
        </div>
      </Imprint>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`imprint.tsx`)

```tsx
"use client";

import * as React from "react";
import { motion } from "motion/react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

/** One thing Imprint can stamp: a picture, a gradient tile, or a set word. */
export type ImprintItem =
  | { /** Image URL. */ src: string; /** Optional caption in the paper margin. */ label?: string }
  | { /** Any CSS background (gradient or color). */ gradient: string; label?: string }
  | { /** Text printed large on the proof. */ text: string; label?: string };

export interface ImprintHandle {
  /**
   * Stamp at container-local coordinates (px from the top-left of the scope).
   * Omit both to stamp at the center. Pass `proof: true` for the larger click stamp.
   */
  stamp: (x?: number, y?: number, options?: { proof?: boolean }) => void;
}

export interface ImprintProps {
  /** Content inside the scope. Stays fully interactive; the trail prints behind it. */
  children?: React.ReactNode;
  className?: string;
  /**
   * What gets stamped, cycled in order. Defaults to a seeded set of soft
   * slate/graphite gradient tiles with short catalog labels.
   */
  items?: ImprintItem[];
  /** Pointer travel (px) between stamps. @default 90 */
  spacing?: number;
  /** Stamp width in px (height is 5:4 of it). Click proofs are 1.45x. @default 140 */
  size?: number;
  /** Most stamps on the pile at once; the oldest lift off beyond it. @default 12 */
  max?: number;
  /** How long (ms) a stamp rests before it fades and desaturates away. @default 1600 */
  life?: number;
  /** Max seeded tilt either way, in degrees. @default 6 */
  rotate?: number;
  /** Ghost autoplay along a seeded Lissajous path after 1.5s without a pointer. @default true */
  idle?: boolean;
  /** Seed for the ghost path, tilts, registration offsets and default tiles. @default 7 */
  seed?: number;
  /** Stop stamping (pointer and ghost). Existing stamps finish aging out. @default false */
  paused?: boolean;
  /** React 19 ref prop — imperative `stamp(x, y)`. */
  ref?: React.Ref<ImprintHandle>;
}

interface Stamp {
  id: number;
  x: number;
  y: number;
  rot: number;
  dx: number;
  dy: number;
  item: number;
  proof: boolean;
  retiring: boolean;
}

const IDLE_MS = 1500;
const GHOST_SPEED = 300; // px/s along the ghost path
const MAX_PER_MOVE = 4; // stamps interpolated from a single fast pointer event
const LAND_S = 0.16;
const AGE_S = 0.65;
const RETIRE_S = 0.2;

/** Deterministic 0–1 hash of (seed, n) — stable across server and client. */
function hash01(seed: number, n: number): number {
  let t = (seed * 0x9e3779b1 + n * 0x85ebca6b) | 0;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}

function defaultItems(seed: number): ImprintItem[] {
  const out: ImprintItem[] = [];
  for (let i = 0; i < 8; i++) {
    const r = (k: number) => hash01(seed + 101, i * 7 + k);
    // Neutral-cool family: slate, graphite, fog, one muted ink blue.
    const hue = 205 + r(0) * 30;
    const sat = i % 4 === 2 ? 38 : 6 + r(1) * 14;
    const l1 = 18 + r(2) * 22;
    const l2 = l1 + 22 + r(3) * 30;
    const angle = Math.round(r(4) * 360);
    const spot = `radial-gradient(circle at ${Math.round(20 + r(5) * 60)}% ${Math.round(15 + r(6) * 50)}%, hsl(${hue.toFixed(0)} ${sat.toFixed(0)}% ${Math.min(92, l2 + 18).toFixed(0)}% / 0.75), transparent 55%)`;
    out.push({
      gradient: `${spot}, linear-gradient(${angle}deg, hsl(${hue.toFixed(0)} ${sat.toFixed(0)}% ${l1.toFixed(0)}%), hsl(${(hue + 12).toFixed(0)} ${sat.toFixed(0)}% ${l2.toFixed(0)}%))`,
      label: `No. ${String(i + 1).padStart(2, "0")}`,
    });
  }
  return out;
}

function ItemFace({ item }: { item: ImprintItem }) {
  if ("src" in item) {
    return (
      // eslint-disable-next-line @next/next/no-img-element -- shipped registry source stays framework-agnostic
      <img src={item.src} alt="" draggable={false} className="h-full w-full object-cover" />
    );
  }
  if ("gradient" in item) {
    return <div className="h-full w-full" style={{ background: item.gradient }} />;
  }
  return (
    <div className="flex h-full w-full items-center justify-center bg-neutral-900 p-2 text-center font-serif text-[1.35em] leading-none font-semibold tracking-tight text-neutral-100">
      {item.text}
    </div>
  );
}

/** Paper proof: neutral sheet with an inked plate and a caption in the margin. */
function Proof({ item, w, h, proof }: { item: ImprintItem; w: number; h: number; proof: boolean }) {
  const pad = Math.max(4, Math.round(w * 0.045));
  const label = item.label ?? (proof ? "Proof" : undefined);
  return (
    <div
      className="flex h-full w-full flex-col bg-neutral-100"
      style={{ padding: pad, paddingBottom: label ? pad * 3.2 : pad, fontSize: Math.max(10, w * 0.12) }}
    >
      <div className="relative min-h-0 flex-1 overflow-hidden">
        <ItemFace item={item} />
        {/* Ink texture: a soft inner edge so the plate reads pressed, not pasted. */}
        <div className="pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.12),inset_0_0_12px_rgba(0,0,0,0.18)]" />
      </div>
      {label ? (
        <div
          className="flex items-end justify-between font-mono tracking-[0.12em] text-neutral-500 uppercase"
          style={{ height: pad * 2.2, fontSize: Math.max(7, Math.min(11, w * 0.06)) }}
        >
          <span>{proof ? "Proof" : label}</span>
          <span>{Math.round(w)}×{Math.round(h)}</span>
        </div>
      ) : null}
    </div>
  );
}

const StampView = React.memo(function StampView({
  stamp,
  item,
  size,
  life,
  onDone,
}: {
  stamp: Stamp;
  item: ImprintItem;
  size: number;
  life: number;
  onDone: (id: number) => void;
}) {
  const w = stamp.proof ? size * 1.45 : size;
  const h = w * 1.25;
  return (
    <motion.div
      className="absolute"
      style={{ left: stamp.x - w / 2, top: stamp.y - h / 2, width: w, height: h, rotate: stamp.rot }}
      initial={{ scale: stamp.proof ? 1.12 : 1.06, y: stamp.proof ? 7 : 4, opacity: 0 }}
      animate={{ scale: 1, y: 0, opacity: 1 }}
      transition={{
        duration: stamp.proof ? LAND_S * 1.4 : LAND_S,
        ease: [0.2, 0.8, 0.3, 1],
        opacity: { duration: 0.06 },
      }}
    >
      <motion.div
        className="relative h-full w-full"
        initial={{ opacity: 1, filter: "grayscale(0) brightness(1) contrast(1)" }}
        animate={
          stamp.retiring
            ? // Distinct target so motion restarts with the fast retire transition.
              { opacity: 0, filter: "grayscale(1) brightness(0.8) contrast(0.9)" }
            : { opacity: 0, filter: "grayscale(1) brightness(0.85) contrast(0.9)" }
        }
        transition={
          stamp.retiring
            ? { duration: RETIRE_S, ease: "easeOut" }
            : {
                delay: life / 1000,
                duration: AGE_S,
                ease: "easeIn",
                // Desaturate leads the fade so it reads as paper aging, not vanishing.
                filter: { delay: life / 1000 - AGE_S * 0.4, duration: AGE_S * 1.2, ease: "easeOut" },
              }
        }
        onAnimationComplete={() => onDone(stamp.id)}
      >
        {/* Ink spread: blooms on impact, then tightens to a resting shadow. */}
        <motion.div
          className="absolute inset-0"
          initial={{ boxShadow: "0px 0px 0px 0px rgba(0,0,0,0)" }}
          animate={{
            boxShadow: [
              "0px 0px 0px 0px rgba(0,0,0,0)",
              stamp.proof ? "0px 22px 52px 10px rgba(0,0,0,0.6)" : "0px 16px 38px 6px rgba(0,0,0,0.55)",
              stamp.proof ? "0px 10px 22px 0px rgba(0,0,0,0.5)" : "0px 6px 14px 0px rgba(0,0,0,0.45)",
            ],
          }}
          transition={{ duration: 0.45, times: [0, 0.3, 1], ease: "easeOut" }}
        />
        <div className="absolute inset-0 overflow-hidden">
          <Proof item={item} w={w} h={h} proof={stamp.proof} />
        </div>
        {/* Registration copy: a faint misaligned second pass that snaps into place. */}
        <motion.div
          className="absolute inset-0 overflow-hidden"
          initial={{ x: stamp.dx, y: stamp.dy, opacity: 0.18 }}
          animate={{ x: 0, y: 0, opacity: [0.18, 0.18, 0] }}
          transition={{ duration: 0.24, ease: [0.5, 0, 0.2, 1], opacity: { duration: 0.3, times: [0, 0.75, 1] } }}
        >
          <Proof item={item} w={w} h={h} proof={stamp.proof} />
        </motion.div>
      </motion.div>
    </motion.div>
  );
});

/**
 * Imprint — a letterpress image trail. Moving the pointer (mouse, pen or
 * touch) through the scoped container stamps a pile of proofs every `spacing`
 * px of travel. Each lands with a quick settle, a seeded tilt, a faint
 * registration double that snaps into place and an ink-spread shadow that
 * blooms then tightens; after `life` ms it desaturates and fades like aging
 * paper. Clicks stack larger "proof" stamps. With no pointer for 1.5s a seeded
 * Lissajous ghost keeps printing (paused offscreen and on hidden tabs). The
 * trail layer is decorative and `pointer-events-none`; children stay
 * interactive. Under reduced motion there is no trail — a static loose pile
 * of three proofs rests in the corner.
 */
export function Imprint({
  children,
  className,
  items,
  spacing = 90,
  size = 140,
  max = 12,
  life = 1600,
  rotate = 6,
  idle = true,
  seed = 7,
  paused = false,
  ref,
}: ImprintProps) {
  const reducedMotion = useReducedMotion();
  const fallbackItems = React.useMemo(() => defaultItems(seed), [seed]);
  const pool = items && items.length > 0 ? items : fallbackItems;

  const [stamps, setStamps] = React.useState<Stamp[]>([]);

  // Hot-path state lives in refs; React only hears about it once per frame.
  const pendingRef = React.useRef<Stamp[]>([]);
  const flushRafRef = React.useRef(0);
  const counterRef = React.useRef(0);
  const anchorRef = React.useRef<{ x: number; y: number } | null>(null);
  const lastActivityRef = React.useRef(0);
  const sizeRef = React.useRef({ w: 0, h: 0 });
  const ghostRef = React.useRef<{ t: number; last: number; travel: number; px: number; py: number; on: boolean }>({
    t: 0,
    last: 0,
    travel: 0,
    px: 0,
    py: 0,
    on: false,
  });

  const optsRef = React.useRef({ spacing, max, rotate, seed, pool, paused, reducedMotion });
  optsRef.current = { spacing, max, rotate, seed, pool, paused, reducedMotion };

  const flush = React.useCallback(() => {
    flushRafRef.current = 0;
    const fresh = pendingRef.current;
    if (fresh.length === 0) return;
    pendingRef.current = [];
    const cap = Math.max(1, optsRef.current.max);
    setStamps((prev) => {
      let next = prev.concat(fresh);
      // Beyond the cap the oldest live stamps lift off quickly instead of popping.
      let live = 0;
      for (let i = next.length - 1; i >= 0; i--) {
        if (next[i].retiring) continue;
        live++;
        if (live > cap) next[i] = { ...next[i], retiring: true };
      }
      // Hard ceiling on DOM nodes even under extreme input.
      if (next.length > cap * 2) next = next.slice(next.length - cap * 2);
      return next;
    });
  }, []);

  const enqueue = React.useCallback(
    (x: number, y: number, proof = false) => {
      const o = optsRef.current;
      if (o.reducedMotion) return;
      const n = counterRef.current++;
      const r = (k: number) => hash01(o.seed, n * 5 + k);
      const angle = r(2) * Math.PI * 2;
      const off = 1 + r(3); // 1–2px registration error
      pendingRef.current.push({
        id: n,
        x,
        y,
        rot: (r(0) * 2 - 1) * o.rotate * (proof ? 0.6 : 1),
        dx: Math.cos(angle) * off,
        dy: Math.sin(angle) * off,
        item: n % o.pool.length,
        proof,
        retiring: false,
      });
      if (!flushRafRef.current) flushRafRef.current = requestAnimationFrame(flush);
    },
    [flush]
  );

  const onDone = React.useCallback((id: number) => {
    setStamps((prev) => prev.filter((s) => s.id !== id));
  }, []);

  /** Advance an anchor toward (x, y), stamping every `spacing` px of travel. */
  const travel = React.useCallback(
    (x: number, y: number) => {
      const step = Math.max(8, optsRef.current.spacing);
      const a = anchorRef.current;
      if (!a) {
        anchorRef.current = { x, y };
        enqueue(x, y);
        return;
      }
      let dist = Math.hypot(x - a.x, y - a.y);
      let made = 0;
      while (dist >= step && made < MAX_PER_MOVE) {
        const f = step / dist;
        a.x += (x - a.x) * f;
        a.y += (y - a.y) * f;
        enqueue(a.x, a.y);
        made++;
        dist = Math.hypot(x - a.x, y - a.y);
      }
      // A flick longer than the per-event budget re-anchors at the pointer.
      if (made === MAX_PER_MOVE && dist >= step) {
        a.x = x;
        a.y = y;
      }
    },
    [enqueue]
  );

  // Ghost driver: rAF via useVisibilityPause, so it sleeps offscreen / hidden.
  const tick = React.useCallback(
    (elapsed: number) => {
      const g = ghostRef.current;
      const dt = Math.min(Math.max(elapsed - g.last, 0), 0.05);
      g.last = elapsed;
      const { w, h } = sizeRef.current;
      if (w < 2 || h < 2) return;
      const now = performance.now();
      if (now - lastActivityRef.current < IDLE_MS) {
        g.on = false;
        return;
      }
      const s = optsRef.current.seed;
      const fa = 2 + Math.floor(hash01(s, 900) * 2); // 2 or 3
      const fb = fa + 1 + Math.floor(hash01(s, 901) * 2);
      const phase = hash01(s, 902) * Math.PI * 2;
      const ax = w * 0.36;
      const ay = h * 0.32;
      // Parametric speed roughly normalised to GHOST_SPEED px/s.
      g.t += (dt * GHOST_SPEED) / Math.max(120, (ax * fa + ay * fb) * 0.75);
      const x = w / 2 + ax * Math.sin(fa * g.t + phase);
      const y = h / 2 + ay * Math.sin(fb * g.t);
      if (!g.on) {
        g.on = true;
        g.px = x;
        g.py = y;
        g.travel = optsRef.current.spacing * 0.6;
        return;
      }
      g.travel += Math.hypot(x - g.px, y - g.py);
      g.px = x;
      g.py = y;
      if (g.travel >= Math.max(8, optsRef.current.spacing)) {
        g.travel = 0;
        enqueue(x, y);
      }
    },
    [enqueue]
  );

  const scopeRef = useVisibilityPause<HTMLDivElement>(tick, { paused: !idle || paused || reducedMotion });

  React.useEffect(() => {
    const el = scopeRef.current;
    if (!el) return;
    lastActivityRef.current = performance.now();

    const ro = new ResizeObserver(() => {
      sizeRef.current = { w: el.clientWidth, h: el.clientHeight };
    });
    ro.observe(el);
    sizeRef.current = { w: el.clientWidth, h: el.clientHeight };

    const local = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      return { x: e.clientX - rect.left, y: e.clientY - rect.top };
    };
    const onMove = (e: PointerEvent) => {
      lastActivityRef.current = performance.now();
      ghostRef.current.on = false;
      if (optsRef.current.paused) return;
      const p = local(e);
      travel(p.x, p.y);
    };
    const onDown = (e: PointerEvent) => {
      lastActivityRef.current = performance.now();
      if (optsRef.current.paused || e.button > 0) return;
      const p = local(e);
      enqueue(p.x, p.y, true);
      anchorRef.current = { x: p.x, y: p.y };
    };
    const onLeave = () => {
      anchorRef.current = null;
    };

    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerdown", onDown);
    el.addEventListener("pointerleave", onLeave);
    el.addEventListener("pointercancel", onLeave);
    return () => {
      ro.disconnect();
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerdown", onDown);
      el.removeEventListener("pointerleave", onLeave);
      el.removeEventListener("pointercancel", onLeave);
      if (flushRafRef.current) cancelAnimationFrame(flushRafRef.current);
      flushRafRef.current = 0;
      pendingRef.current = [];
      anchorRef.current = null;
    };
  }, [scopeRef, travel, enqueue]);

  // Reduced motion (or a live toggle into it): clear the trail.
  React.useEffect(() => {
    if (reducedMotion) {
      pendingRef.current = [];
      setStamps([]);
    }
  }, [reducedMotion]);

  React.useImperativeHandle(
    ref,
    () => ({
      stamp(x, y, options) {
        const { w, h } = sizeRef.current;
        enqueue(x ?? w / 2, y ?? h / 2, options?.proof ?? false);
      },
    }),
    [enqueue]
  );

  return (
    <div ref={scopeRef} data-crucible="imprint" className={cn("relative isolate overflow-hidden", className)}>
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10 overflow-hidden select-none">
        {reducedMotion ? (
          <StaticPile pool={pool} size={size} seed={seed} rotate={rotate} />
        ) : (
          stamps.map((s) => (
            <StampView
              key={s.id}
              stamp={s}
              item={pool[s.item % pool.length]}
              size={size}
              life={life}
              onDone={onDone}
            />
          ))
        )}
      </div>
      {children}
    </div>
  );
}

/** Reduced-motion resting frame: three proofs loosely piled in the bottom-right corner. */
function StaticPile({ pool, size, seed, rotate }: { pool: ImprintItem[]; size: number; seed: number; rotate: number }) {
  const w = Math.min(size, 120);
  const h = w * 1.25;
  return (
    <div className="absolute right-6 bottom-6" style={{ width: w * 1.5, height: h * 1.25 }}>
      {[0, 1, 2].map((i) => {
        const rot = (hash01(seed, 500 + i) * 2 - 1) * Math.max(rotate, 4) * 1.4;
        return (
          <div
            key={i}
            className="absolute shadow-[0_6px_14px_rgba(0,0,0,0.45)]"
            style={{
              width: w,
              height: h,
              right: i * w * 0.18,
              bottom: i * h * 0.08,
              transform: `rotate(${rot.toFixed(2)}deg)`,
              filter: i === 2 ? undefined : `grayscale(${0.5 - i * 0.25})`,
              opacity: 0.75 + i * 0.12,
            }}
          >
            <Proof item={pool[i % pool.length]} w={w} h={h} proof={false} />
          </div>
        );
      })}
    </div>
  );
}
```
