Imprint
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.
motionfree
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/imprintManual — install dependencies, then copy the source
npm install motionProps
| 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). |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.