Imprint
3D Box Grid Background for React
A background wall of boxes that takes an imprint of the pointer. The boxes under it sink back along the z axis to one flat floor, the boxes around them stand in front with their side walls showing, and the dent fills back in slowly behind the pointer. Press to push deeper and roll a ring out across the wall.
- Background
- Interaction
- Canvas
- 3D
Uses motion, clsx, tailwind-merge
<Imprint />The demo follows as you drag.
Softform turns your design system into components that answer the hand. They press, settle and spring back, out of the box.
import { Imprint } from "@/components/ui/imprint";
export function ImprintDemo() {
return (
<Imprint className="flex h-[var(--demo-height,36rem)] w-full flex-col items-center justify-center rounded-2xl border border-border px-6 text-center text-foreground">
<span className="rounded-full border border-border bg-background px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
Softform 2.0 · now in beta
</span>
<h2 className="mt-6 max-w-[14ch] text-4xl/[1.05] font-semibold tracking-tight text-balance sm:text-6xl/[1.02]">
Interfaces that give a little
</h2>
<p className="mt-5 max-w-md text-base/7 text-pretty text-muted-foreground">
Softform turns your design system into components that answer the hand. They press,
settle and spring back, out of the box.
</p>
<div className="mt-9 flex items-center gap-3">
<a
href="#"
className="h-11 rounded-full bg-foreground px-6 text-sm/[2.75rem] font-medium text-background shadow-sm transition-transform active:scale-[0.97]"
>
Start building
</a>
<a
href="#"
className="h-11 rounded-full border border-border bg-background px-6 text-sm/[2.75rem] font-medium shadow-sm transition-transform active:scale-[0.97]"
>
Read the docs
</a>
</div>
</Imprint>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Imprint: A background wall of boxes that takes an imprint of the pointer. The boxes under it sink back along the z axis to one flat floor, the boxes around them stand in front with their side walls showing, and the dent fills back in slowly behind the pointer. Press to push deeper and roll a ring out across the wall. This demo, Product Hero: A product hero on the default wall. Move across it and the dent follows with a trail that fills back in; press and hold to push the floor deeper and send a ring out. 1. Install it: npx shadcn@latest add @keyframeui/imprint-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/imprint-demo.json instead.) It adds the demo, components/imprint-demo.tsx, and the component, components/ui/imprint.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Imprint> on its own. 3. These props set its feel. Change them before you change the file: - cell (default 48): Size of one box, in px. - gap (default 2): Width of the seam around each box, in px. `0` gives one seamless surface until it's pressed. - depth (default 48): How far the boxes under the pointer sink, in px. Holding the pointer down pushes them 60% further. - reach (default 140): Radius of the dent around the pointer, in px. - falloff (default 0.45): Shape of the dent, from `0` (one flat floor with a sharp rim) to `1` (a smooth bowl that terraces down). - tilt (default 0.35): How far above the wall the viewer sits. Higher shows more of the walls; `0` looks straight on, with only the perspective showing them. - perspective (default 900): Distance from the viewer to the wall, in px. Lower makes sunk boxes shrink more and their side walls show further from the center. - ripple (default true): Roll a ring out across the wall from every press. - sinkStiffness (default 340): Stiffness of the spring a box sinks on. Higher snaps the dent in under the pointer faster. - riseStiffness (default 64): Stiffness of the spring a box rises back on. Lower fills the dent in more slowly, so the pointer leaves a longer trail. - riseDamping (default 0.62): Damping ratio of the rise. Under `1` a box bobs just past the surface before it settles; `1` and up comes back without the bob. - rippleSpeed (default 820): How fast the ring from a press rolls out across the wall, in px per second. 4. Don't change, in components/ui/imprint.tsx: - One canvas and one rAF loop draw the whole wall. The loop stops once every box settles or the wall leaves the screen, and pointer events only `wake()` it, so nothing re-renders in React. - Each box picks its spring per frame: the stiff, near-critical one while its target is deeper, the soft one that bobs past the surface otherwise. That split is what leaves the trail. - Boxes paint deepest first, then the surface, then any box bobbing in front of it, and a side wall is drawn only where a neighbor sits deeper and the wall faces the viewer. - The dent aims below the pointer by the tilt, so the floor, seen from above, lands under it. - Reduced motion sets each box straight to its target and the press straight to held, with no springs, and no ring rolls out. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/imprint.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/imprint.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/imprintInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/imprint.tsx"use client"; import * as React from "react"; import { useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - One canvas and one rAF loop draw the whole wall. The loop stops once every box settles or the * wall leaves the screen, and pointer events only `wake()` it, so nothing re-renders in React. * - Each box picks its spring per frame: the stiff, near-critical one while its target is deeper, * the soft one that bobs past the surface otherwise. That split is what leaves the trail. * - Boxes paint deepest first, then the surface, then any box bobbing in front of it, and a side * wall is drawn only where a neighbor sits deeper and the wall faces the viewer. * - The dent aims below the pointer by the tilt, so the floor, seen from above, lands under it. * - Reduced motion sets each box straight to its target and the press straight to held, with no * springs, and no ring rolls out. */ const TIMING = { /** The spring a box sinks on: stiffness (the `sinkStiffness` prop) and damping ratio. */ sinkStiffness: 340, sinkDamping: 0.9, /** * The spring a box rises back on: stiffness (the `riseStiffness` prop) and damping ratio (the * `riseDamping` prop), under 1 so a box bobs past the surface. */ riseStiffness: 64, riseDamping: 0.62, /** The press pushing the floor deeper and letting go: time constants, in seconds. */ pressIn: 0.06, pressOut: 0.2, /** How fast the ring from a press rolls out, in px/s (the `rippleSpeed` prop). */ rippleSpeed: 820, /** The time constant the ring's height decays on, in seconds. */ ringDecay: 0.5, } as const; /* ───────────────────────────────────────────────────────── * IMPRINT STORYBOARD * * rest a wall of square boxes, fronts flush, like a pin-art * toy seen face on. Only the seams show * hover boxes under the pointer sink back along the z axis. * Inside `reach` they settle on one flat floor; the rim * steps down to it. The boxes still at the surface * stand in front of the sunk ones, so their side walls * show and they overlap the floor's edge, seen from a * little above (`tilt`) and in perspective * move sinking is quick, rising is slow: the dent follows * the pointer and leaves a soft trail that fills back * in, each box bobbing just past the surface * press holding the pointer down pushes the floor deeper, * and a ring rolls out across the wall from the press * leave every box springs back to the surface * reduced the dent follows the pointer without springs; no * ring * ───────────────────────────────────────────────────────── */ const SURFACE = { shadow: 0.7, // a floor box under a higher neighbor above or to its left shades as if this much deeper, share of the step pressBoost: 0.6, // extra depth while the pointer is held down, share of `depth` ring: { width: 1.1, amp: 0.42 }, // cells, share of `depth`; it rolls out at `rippleSpeed` and fades on `TIMING.ringDecay` levels: 18, // shading steps between the surface and the deepest floor rest: 0.02, // px, a box this close to its target and nearly still is settled maxDpr: 2, } as const; // How each face is shaded: `base` mixes toward white (+) or black (−), `deep` is added // at the deepest floor, `accent` is the accent's share there. Light comes from the top left. // `dark` replaces `base` and `deep` when the boxes are a dark color, where black barely shows. type Shading = { base: number; deep: number; accent: number; dark?: { base: number; deep: number } }; const FACES = { front: { base: 0, deep: -0.3, accent: 0.85, dark: { base: 0, deep: -0.5 } }, seam: { base: -0.09, deep: -0.36, accent: 0.7, dark: { base: 0.07, deep: -0.5 } }, // the line around each front up: { base: 0.2, deep: -0.12, accent: 0.4, dark: { base: 0.16, deep: -0.2 } }, // the wall facing up, at the bottom of a dent left: { base: 0.08, deep: -0.12, accent: 0.3, dark: { base: 0.07, deep: -0.2 } }, right: { base: -0.16, deep: -0.12, accent: 0.25, dark: { base: -0.25, deep: -0.2 } }, down: { base: -0.3, deep: -0.12, accent: 0.2, dark: { base: -0.4, deep: -0.2 } }, } satisfies Record<string, Shading>; type Face = keyof typeof FACES; const FACE_NAMES = Object.keys(FACES) as Face[]; type Options = { cell: number; gap: number; depth: number; reach: number; falloff: number; tilt: number; perspective: number; ripple: boolean; sinkStiffness: number; riseStiffness: number; riseDamping: number; // damping ratio, under 1 so a box bobs past the surface rippleSpeed: number; // px/s reduced: boolean; }; const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); // Whether a CSS color is dark, by painting it on a 1×1 canvas (computed colors may be oklch or color()). function isDark(color: string) { const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return false; ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128; } function createImprint() { let root: HTMLElement | null = null; let canvas: HTMLCanvasElement | null = null; let ctx: CanvasRenderingContext2D | null = null; let probes: { face: HTMLElement; accent: HTMLElement } | null = null; let tinted = false; // an accent class is set const palette = {} as Record<Face, string[]>; let options: Options = { cell: 48, gap: 2, depth: 48, reach: 140, falloff: 0.45, tilt: 0.35, perspective: 900, ripple: true, sinkStiffness: TIMING.sinkStiffness, riseStiffness: TIMING.riseStiffness, riseDamping: TIMING.riseDamping, rippleSpeed: TIMING.rippleSpeed, reduced: false, }; // The grid, rebuilt when the root changes size. Box i sits at column i % cols, row i / cols. let width = 0; let height = 0; let dpr = 1; let cols = 0; let rows = 0; let ox = 0; let oy = 0; let depth = new Float32Array(0); // px behind the surface; negative while bobbing in front of it let speed = new Float32Array(0); const active: number[] = []; const pointer = { x: 0, y: 0, inside: false, down: false, press: 0 }; const rings: { x: number; y: number; t0: number }[] = []; let raf = 0; let last = 0; let visible = false; const cleanups: (() => void)[] = []; function readColors() { if (!probes) return; const el = probes.face; const face = getComputedStyle(el).color; const accent = tinted ? getComputedStyle(probes.accent).color : null; const dark = isDark(face); for (const name of FACE_NAMES) { const f: Shading = FACES[name]; const { base, deep } = (dark && f.dark) || f; palette[name] = []; for (let lv = 0; lv < SURFACE.levels; lv++) { const q = lv / (SURFACE.levels - 1); const tone = Math.round((base + deep * q) * 100); let color = tone >= 0 ? `color-mix(in oklab, ${face}, white ${tone}%)` : `color-mix(in oklab, ${face}, black ${-tone}%)`; // Eased in, so a shallow box or a passing ring barely takes it on and the floor glows. if (accent) color = `color-mix(in oklab, ${color}, ${accent} ${Math.round(f.accent * q * q * 100)}%)`; el.style.color = color; palette[name].push(getComputedStyle(el).color); } } el.style.color = ""; } function layout() { if (!root || !canvas) return; const w = root.clientWidth; const h = root.clientHeight; dpr = Math.min(window.devicePixelRatio || 1, SURFACE.maxDpr); const c = Math.max(8, options.cell); const nextCols = Math.ceil(w / c) + 1; const nextRows = Math.ceil(h / c) + 1; if (w === width && h === height && canvas.width === Math.round(w * dpr) && nextCols === cols && nextRows === rows) { ox = (w - cols * c) / 2; oy = (h - rows * c) / 2; return; } width = w; height = h; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); // One box more than fits, centered, so the wall runs off every edge. cols = nextCols; rows = nextRows; ox = (w - cols * c) / 2; oy = (h - rows * c) / 2; depth = new Float32Array(cols * rows); speed = new Float32Array(cols * rows); } function step(dt: number, t: number) { const { cell, reach, falloff, reduced } = options; const held = pointer.inside && pointer.down ? 1 : 0; if (reduced) pointer.press = held; else { const tau = held > pointer.press ? TIMING.pressIn : TIMING.pressOut; pointer.press += (held - pointer.press) * (1 - Math.exp(-dt / tau)); if (Math.abs(held - pointer.press) < 0.001) pointer.press = held; } let moving = pointer.press !== held; const floor = options.depth * (1 + SURFACE.pressBoost * pointer.press); const inner = reach * (1 - clamp01(falloff)); const reach2 = reach * reach; // The floor is seen from above, so it shows higher than it sits; aim the dent below the pointer to land it under it. const aimX = pointer.x + options.tilt * 0.3 * options.depth * 0.8; const aimY = pointer.y + options.tilt * options.depth * 0.8; // Rings: radius, height and width now; drop the ones that have faded. const ring = SURFACE.ring; const ringWidth = cell * ring.width; const live: { x: number; y: number; r: number; amp: number }[] = []; for (let k = rings.length - 1; k >= 0; k--) { const age = t - rings[k].t0; const amp = options.depth * ring.amp * Math.exp(-age / TIMING.ringDecay); if (amp < 0.3) rings.splice(k, 1); else live.push({ x: rings[k].x, y: rings[k].y, r: age * options.rippleSpeed, amp }); } if (live.length) moving = true; // Sink on a stiff spring, rise on a soft one. const push = { stiffness: options.sinkStiffness, damping: TIMING.sinkDamping }; const release = { stiffness: options.riseStiffness, damping: options.riseDamping }; const pushC = 2 * push.damping * Math.sqrt(push.stiffness); const releaseC = 2 * release.damping * Math.sqrt(release.stiffness); const sub = 2; const h = dt / sub; for (let i = 0; i < depth.length; i++) { const x = ox + ((i % cols) + 0.5) * cell; const y = oy + (((i / cols) | 0) + 0.5) * cell; let target = 0; if (pointer.inside) { const dx = x - aimX; const dy = y - aimY; const r2 = dx * dx + dy * dy; if (r2 < reach2) { const r = Math.sqrt(r2); if (r <= inner) target = floor; else { const u = (reach - r) / (reach - inner); target = floor * u * u * (3 - 2 * u); } } } for (const rg of live) { const u = (Math.hypot(x - rg.x, y - rg.y) - rg.r) / ringWidth; if (u > -3 && u < 3) target += rg.amp * Math.exp(-u * u); } if (reduced) { depth[i] = target; speed[i] = 0; continue; } let d = depth[i]; let v = speed[i]; if (d === target && v === 0) continue; const sinking = target > d && target > SURFACE.rest; const k = sinking ? push.stiffness : release.stiffness; const c = sinking ? pushC : releaseC; for (let s = 0; s < sub; s++) { v += (k * (target - d) - c * v) * h; d += v * h; } if (Math.abs(target - d) < SURFACE.rest && Math.abs(v) < SURFACE.rest * 10) { d = target; v = 0; } else moving = true; depth[i] = d; speed[i] = v; } return moving; } function draw() { if (!ctx) return; const { cell, gap, perspective, tilt } = options; const cx = width / 2; const cy = height / 2; const floor = options.depth * (1 + SURFACE.pressBoost); const top = SURFACE.levels - 1; const level = (d: number) => (d <= 0 ? 0 : Math.min(top, Math.round((d / floor) * top))); // Seen from a little above and to the left, in perspective around the center. const shiftX = -tilt * 0.3; const shiftY = -tilt; const px = (x: number, d: number) => cx + (x - cx) * (perspective / (perspective + d)) + shiftX * d; const py = (y: number, d: number) => cy + (y - cy) * (perspective / (perspective + d)) + shiftY * d; const inset = Math.min(gap, cell / 2) / 2; const n = depth.length; // A wall of box i shows where a neighbor sits deeper, from this box's front back to // the neighbor's. Each is drawn only if it faces the viewer. const wall = (face: Face, ax: number, ay: number, bx: number, by: number, d: number, dn: number) => { ctx!.fillStyle = palette[face][level((d + dn) / 2)]; ctx!.beginPath(); ctx!.moveTo(px(ax, d), py(ay, d)); ctx!.lineTo(px(bx, d), py(by, d)); ctx!.lineTo(px(bx, dn), py(by, dn)); ctx!.lineTo(px(ax, dn), py(ay, dn)); ctx!.fill(); }; const walls = (i: number) => { const col = i % cols; const row = (i / cols) | 0; const d = depth[i]; const x0 = ox + col * cell; const x1 = x0 + cell; const y0 = oy + row * cell; const y1 = y0 + cell; const left = col > 0 ? depth[i - 1] : 0; const right = col < cols - 1 ? depth[i + 1] : 0; const up = row > 0 ? depth[i - cols] : 0; const down = row < rows - 1 ? depth[i + cols] : 0; if (up > d && py(y0, up) < py(y0, d)) wall("up", x0, y0, x1, y0, d, up); if (down > d && py(y1, down) > py(y1, d)) wall("down", x0, y1, x1, y1, d, down); if (left > d && px(x0, left) < px(x0, d)) wall("left", x0, y0, x0, y1, d, left); if (right > d && px(x1, right) > px(x1, d)) wall("right", x1, y0, x1, y1, d, right); }; const front = (i: number) => { const col = i % cols; const row = (i / cols) | 0; const d = depth[i]; // Shade a sunk box more under a step above or to its left, where the light is blocked. const up = row > 0 ? depth[i - cols] : 0; const left = col > 0 ? depth[i - 1] : 0; const shade = level(d + Math.max(0, d - Math.min(up, left)) * SURFACE.shadow); const x0 = ox + col * cell; const y0 = oy + row * cell; const sx0 = px(x0, d); const sy0 = py(y0, d); const sx1 = px(x0 + cell, d); const sy1 = py(y0 + cell, d); ctx!.fillStyle = palette.seam[shade]; ctx!.fillRect(sx0, sy0, sx1 - sx0, sy1 - sy0); const k = (sx1 - sx0) / cell; ctx!.fillStyle = palette.front[shade]; ctx!.fillRect(sx0 + inset * k, sy0 + inset * k, sx1 - sx0 - 2 * inset * k, sy1 - sy0 - 2 * inset * k); }; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); // Painter's order: the deepest boxes first, then the surface, then any box bobbing in front of it. active.length = 0; for (let i = 0; i < n; i++) if (depth[i] !== 0) active.push(i); active.sort((a, b) => depth[b] - depth[a]); let k = 0; for (; k < active.length && depth[active[k]] > 0; k++) { walls(active[k]); front(active[k]); } if (active.length) for (let i = 0; i < n; i++) if (depth[i] === 0) walls(i); ctx.fillStyle = palette.seam[0]; ctx.beginPath(); for (let i = 0; i < n; i++) { if (depth[i] !== 0) continue; ctx.rect(ox + (i % cols) * cell, oy + ((i / cols) | 0) * cell, cell, cell); } ctx.fill(); ctx.fillStyle = palette.front[0]; ctx.beginPath(); for (let i = 0; i < n; i++) { if (depth[i] !== 0) continue; const col = i % cols; const row = (i / cols) | 0; ctx.rect(ox + col * cell + inset, oy + row * cell + inset, cell - inset * 2, cell - inset * 2); } ctx.fill(); for (; k < active.length; k++) { walls(active[k]); front(active[k]); } } function frame(time: number) { raf = 0; const t = time / 1000; const dt = last ? Math.min(t - last, 1 / 30) : 1 / 60; last = t; const moving = step(dt, t); draw(); if (moving && visible) raf = requestAnimationFrame(frame); else last = 0; } function wake() { if (!raf && visible && ctx) raf = requestAnimationFrame(frame); } return { connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; face: HTMLElement; accent: HTMLElement }) { root = el.root; canvas = el.canvas; ctx = canvas.getContext("2d"); probes = { face: el.face, accent: el.accent }; readColors(); width = 0; layout(); const resize = new ResizeObserver(() => { layout(); wake(); }); resize.observe(root); const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) wake(); }); io.observe(root); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); const recolor = () => { readColors(); wake(); }; scheme.addEventListener("change", recolor); const themeAttrs = new MutationObserver(recolor); themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] }); const locate = (e: PointerEvent) => { const r = root!.getBoundingClientRect(); pointer.x = e.clientX - r.left; pointer.y = e.clientY - r.top; }; const move = (e: PointerEvent) => { // A finger only presses while it's down. if (e.pointerType === "touch" && !pointer.down) return; locate(e); pointer.inside = true; wake(); }; const down = (e: PointerEvent) => { locate(e); pointer.inside = true; pointer.down = true; if (options.ripple && !options.reduced) rings.push({ x: pointer.x, y: pointer.y, t0: performance.now() / 1000 }); wake(); }; const up = (e: PointerEvent) => { pointer.down = false; if (e.pointerType === "touch") pointer.inside = false; wake(); }; const leave = () => { pointer.inside = false; pointer.down = false; wake(); }; root.addEventListener("pointermove", move); root.addEventListener("pointerdown", down); window.addEventListener("pointerup", up); root.addEventListener("pointerleave", leave); root.addEventListener("pointercancel", leave); cleanups.push( () => resize.disconnect(), () => io.disconnect(), () => scheme.removeEventListener("change", recolor), () => themeAttrs.disconnect(), () => { root?.removeEventListener("pointermove", move); root?.removeEventListener("pointerdown", down); window.removeEventListener("pointerup", up); root?.removeEventListener("pointerleave", leave); root?.removeEventListener("pointercancel", leave); }, ); return () => { cancelAnimationFrame(raf); raf = 0; last = 0; cleanups.splice(0).forEach((fn) => fn()); root = canvas = null; ctx = null; probes = null; }; }, setOptions(next: Options) { const regrid = next.cell !== options.cell; options = next; if (regrid) { cols = rows = 0; layout(); } wake(); // Draw once even when nothing moves, so a new size or color shows. if (ctx && !raf) draw(); }, recolor(accent: boolean) { tinted = accent; readColors(); if (ctx && !raf) draw(); }, }; } export interface ImprintProps extends React.HTMLAttributes<HTMLDivElement> { /** Size of one box, in px. */ cell?: number; /** Seam between boxes, in px. */ gap?: number; /** How far the boxes under the pointer sink, in px. */ depth?: number; /** Radius of the dent around the pointer, in px. */ reach?: number; /** Shape of the dent, from 0 (one flat floor with a sharp rim) to 1 (a smooth bowl). */ falloff?: number; /** How far above the wall the viewer sits. Higher shows more of the walls. `0` looks straight on. */ tilt?: number; /** Distance from the viewer to the wall, in px. Lower is a stronger perspective. */ perspective?: number; /** Roll a ring out across the wall from every press. */ ripple?: boolean; /** Stiffness of the spring a box sinks on. Higher snaps the dent in under the pointer faster. */ sinkStiffness?: number; /** Stiffness of the spring a box rises back on. Lower fills the dent in more slowly, for a longer trail. */ riseStiffness?: number; /** Damping ratio of the rise. Under `1` a box bobs past the surface before it settles; `1` and up comes back without it. */ riseDamping?: number; /** How fast the ring from a press rolls out across the wall, in px per second. */ rippleSpeed?: number; /** Classes that set the boxes' color (a `text-*` color). Walls and floor are shaded from it. */ boxClassName?: string; /** Classes that tint the floor of the dent (a `text-*` color). No tint when unset. */ accentClassName?: string; /** Classes for the canvas, e.g. an opacity or a mask. */ canvasClassName?: string; } export function Imprint({ cell = 48, gap = 2, depth = 48, reach = 140, falloff = 0.45, tilt = 0.35, perspective = 900, ripple = true, sinkStiffness = TIMING.sinkStiffness, riseStiffness = TIMING.riseStiffness, riseDamping = TIMING.riseDamping, rippleSpeed = TIMING.rippleSpeed, boxClassName, accentClassName, canvasClassName, className, children, ...props }: ImprintProps) { const rootRef = React.useRef<HTMLDivElement>(null); const canvasRef = React.useRef<HTMLCanvasElement>(null); const faceRef = React.useRef<HTMLSpanElement>(null); const accentRef = React.useRef<HTMLSpanElement>(null); const [imprint] = React.useState(createImprint); const reduced = useReducedMotion() ?? false; React.useLayoutEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const face = faceRef.current; const accent = accentRef.current; if (!root || !canvas || !face || !accent) return; return imprint.connect({ root, canvas, face, accent }); }, [imprint]); React.useEffect(() => { imprint.setOptions({ cell, gap, depth, reach, falloff, tilt, perspective, ripple, sinkStiffness, riseStiffness, riseDamping, rippleSpeed, reduced, }); }, [ imprint, cell, gap, depth, reach, falloff, tilt, perspective, ripple, sinkStiffness, riseStiffness, riseDamping, rippleSpeed, reduced, ]); React.useEffect(() => { imprint.recolor(Boolean(accentClassName)); }, [imprint, boxClassName, accentClassName]); return ( <div ref={rootRef} className={cn("relative isolate overflow-hidden", className)} {...props}> <canvas ref={canvasRef} aria-hidden className={cn("pointer-events-none absolute inset-0 -z-10 size-full", canvasClassName)} /> {/* Probes the canvas reads its colors from. The default applies only when no class is given, so a `dark:` default can't win over yours. */} <span ref={faceRef} aria-hidden className={cn("hidden", boxClassName ?? "text-zinc-100 dark:text-zinc-900")} /> <span ref={accentRef} aria-hidden className={cn("hidden", accentClassName)} /> {children} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Imprint. Each one installs whole, with the component included.
Studio Card
A studio's closing card in stone, with big boxes and `falloff={0}`: every box in reach drops to the same floor, like a row of keys going down, and the floor glows orange through `accentClassName`.
- Product design
- Design systems
- Motion
- Prototyping
import { Imprint } from "@/components/ui/imprint";
const services = ["Product design", "Design systems", "Motion", "Prototyping"];
export function ImprintStudioDemo() {
return (
<Imprint
cell={64}
depth={64}
reach={110}
falloff={0}
boxClassName="text-stone-100 dark:text-stone-900"
accentClassName="text-orange-500"
className="w-full max-w-3xl rounded-2xl border border-stone-200 text-stone-900 dark:border-stone-800 dark:text-stone-100"
>
<div className="flex min-h-[26rem] flex-col justify-between gap-12 p-8 sm:p-12">
<span className="text-sm font-medium tracking-tight">Kiln Studio</span>
<div>
<h2 className="max-w-[16ch] font-serif text-4xl/[1.05] tracking-tight text-balance sm:text-5xl/[1.02]">
We make the parts people touch.
</h2>
<ul className="mt-6 flex flex-wrap gap-2">
{services.map((s) => (
<li
key={s}
className="rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-600 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-400"
>
{s}
</li>
))}
</ul>
</div>
<div className="flex items-end justify-between gap-6">
<a
href="#"
className="rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-transform active:scale-[0.97]"
>
Start a project
</a>
<span className="text-right text-xs text-stone-500">Booking from March</span>
</div>
</div>
</Imprint>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
Imprint: A background wall of boxes that takes an imprint of the pointer. The boxes under it sink back along the z axis to one flat floor, the boxes around them stand in front with their side walls showing, and the dent fills back in slowly behind the pointer. Press to push deeper and roll a ring out across the wall.
This demo, Studio Card: A studio's closing card in stone, with big boxes and `falloff={0}`: every box in reach drops to the same floor, like a row of keys going down, and the floor glows orange through `accentClassName`.
1. Install it:
npx shadcn@latest add @keyframeui/imprint-studio-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/imprint-studio-demo.json instead.)
It adds the demo, components/imprint-studio-demo.tsx, and the component, components/ui/imprint.tsx.
2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Imprint> on its own.
3. These props set its feel. Change them before you change the file:
- cell (default 48): Size of one box, in px.
- gap (default 2): Width of the seam around each box, in px. `0` gives one seamless surface until it's pressed.
- depth (default 48): How far the boxes under the pointer sink, in px. Holding the pointer down pushes them 60% further.
- reach (default 140): Radius of the dent around the pointer, in px.
- falloff (default 0.45): Shape of the dent, from `0` (one flat floor with a sharp rim) to `1` (a smooth bowl that terraces down).
- tilt (default 0.35): How far above the wall the viewer sits. Higher shows more of the walls; `0` looks straight on, with only the perspective showing them.
- perspective (default 900): Distance from the viewer to the wall, in px. Lower makes sunk boxes shrink more and their side walls show further from the center.
- ripple (default true): Roll a ring out across the wall from every press.
- sinkStiffness (default 340): Stiffness of the spring a box sinks on. Higher snaps the dent in under the pointer faster.
- riseStiffness (default 64): Stiffness of the spring a box rises back on. Lower fills the dent in more slowly, so the pointer leaves a longer trail.
- riseDamping (default 0.62): Damping ratio of the rise. Under `1` a box bobs just past the surface before it settles; `1` and up comes back without the bob.
- rippleSpeed (default 820): How fast the ring from a press rolls out across the wall, in px per second.
4. Don't change, in components/ui/imprint.tsx:
- One canvas and one rAF loop draw the whole wall. The loop stops once every box settles or the wall leaves the screen, and pointer events only `wake()` it, so nothing re-renders in React.
- Each box picks its spring per frame: the stiff, near-critical one while its target is deeper, the soft one that bobs past the surface otherwise. That split is what leaves the trail.
- Boxes paint deepest first, then the surface, then any box bobbing in front of it, and a side wall is drawn only where a neighbor sits deeper and the wall faces the viewer.
- The dent aims below the pointer by the tilt, so the floor, seen from above, lands under it.
- Reduced motion sets each box straight to its target and the press straight to held, with no springs, and no ring rolls out.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/imprint.mdNot Found
A 404 page on small boxes with `falloff={1}` and a closer `perspective`, so the dent is a smooth bowl that terraces down, and the page itself has sunk.
404
It may have moved, or the link has a typo. Everything else is still on the surface.
Back to the surfaceimport { Imprint } from "@/components/ui/imprint";
export function ImprintNotFoundDemo() {
return (
<Imprint
cell={28}
gap={1}
depth={40}
reach={190}
falloff={1}
perspective={600}
boxClassName="text-white dark:text-neutral-900"
className="flex h-[var(--demo-height,30rem)] w-full flex-col items-center justify-center rounded-2xl border border-border px-6 text-center text-foreground"
>
<p className="font-mono text-sm text-muted-foreground">404</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
This page sank without a trace
</h2>
<p className="mt-3 max-w-sm text-sm/6 text-pretty text-muted-foreground">
It may have moved, or the link has a typo. Everything else is still on the surface.
</p>
<a
href="#"
className="mt-8 rounded-full bg-foreground px-5 py-2.5 text-sm font-medium text-background shadow-sm transition-transform active:scale-[0.97]"
>
Back to the surface
</a>
</Imprint>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
Imprint: A background wall of boxes that takes an imprint of the pointer. The boxes under it sink back along the z axis to one flat floor, the boxes around them stand in front with their side walls showing, and the dent fills back in slowly behind the pointer. Press to push deeper and roll a ring out across the wall.
This demo, Not Found: A 404 page on small boxes with `falloff={1}` and a closer `perspective`, so the dent is a smooth bowl that terraces down, and the page itself has sunk.
1. Install it:
npx shadcn@latest add @keyframeui/imprint-not-found-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/imprint-not-found-demo.json instead.)
It adds the demo, components/imprint-not-found-demo.tsx, and the component, components/ui/imprint.tsx.
2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Imprint> on its own.
3. These props set its feel. Change them before you change the file:
- cell (default 48): Size of one box, in px.
- gap (default 2): Width of the seam around each box, in px. `0` gives one seamless surface until it's pressed.
- depth (default 48): How far the boxes under the pointer sink, in px. Holding the pointer down pushes them 60% further.
- reach (default 140): Radius of the dent around the pointer, in px.
- falloff (default 0.45): Shape of the dent, from `0` (one flat floor with a sharp rim) to `1` (a smooth bowl that terraces down).
- tilt (default 0.35): How far above the wall the viewer sits. Higher shows more of the walls; `0` looks straight on, with only the perspective showing them.
- perspective (default 900): Distance from the viewer to the wall, in px. Lower makes sunk boxes shrink more and their side walls show further from the center.
- ripple (default true): Roll a ring out across the wall from every press.
- sinkStiffness (default 340): Stiffness of the spring a box sinks on. Higher snaps the dent in under the pointer faster.
- riseStiffness (default 64): Stiffness of the spring a box rises back on. Lower fills the dent in more slowly, so the pointer leaves a longer trail.
- riseDamping (default 0.62): Damping ratio of the rise. Under `1` a box bobs just past the surface before it settles; `1` and up comes back without the bob.
- rippleSpeed (default 820): How fast the ring from a press rolls out across the wall, in px per second.
4. Don't change, in components/ui/imprint.tsx:
- One canvas and one rAF loop draw the whole wall. The loop stops once every box settles or the wall leaves the screen, and pointer events only `wake()` it, so nothing re-renders in React.
- Each box picks its spring per frame: the stiff, near-critical one while its target is deeper, the soft one that bobs past the surface otherwise. That split is what leaves the trail.
- Boxes paint deepest first, then the surface, then any box bobbing in front of it, and a side wall is drawn only where a neighbor sits deeper and the wall faces the viewer.
- The dent aims below the pointer by the tilt, so the floor, seen from above, lands under it.
- Reduced motion sets each box straight to its target and the press straight to held, with no springs, and no ring rolls out.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/imprint.mdProps
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | None | Content laid over the wall. The pointer still presses the boxes under it. |
cell | number | 48 | Size of one box, in px. |
gap | number | 2 | Width of the seam around each box, in px. 0 gives one seamless surface until it's pressed. |
depth | number | 48 | How far the boxes under the pointer sink, in px. Holding the pointer down pushes them 60% further. |
reach | number | 140 | Radius of the dent around the pointer, in px. |
falloff | number | 0.45 | Shape of the dent, from 0 (one flat floor with a sharp rim) to 1 (a smooth bowl that terraces down). |
tilt | number | 0.35 | How far above the wall the viewer sits. Higher shows more of the walls; 0 looks straight on, with only the perspective showing them. |
perspective | number | 900 | Distance from the viewer to the wall, in px. Lower makes sunk boxes shrink more and their side walls show further from the center. |
ripple | boolean | true | Roll a ring out across the wall from every press. |
sinkStiffness | number | 340 | Stiffness of the spring a box sinks on. Higher snaps the dent in under the pointer faster. |
riseStiffness | number | 64 | Stiffness of the spring a box rises back on. Lower fills the dent in more slowly, so the pointer leaves a longer trail. |
riseDamping | number | 0.62 | Damping ratio of the rise. Under 1 a box bobs just past the surface before it settles; 1 and up comes back without the bob. |
rippleSpeed | number | 820 | How fast the ring from a press rolls out across the wall, in px per second. |
boxClassName | string | "text-zinc-100 dark:text-zinc-900" | Classes that set the boxes' color (a text-* color). Seams, walls and the floor are shaded from it, lighter seams on a dark color and darker ones on a light color. |
accentClassName | string | None | Classes that tint the floor of the dent (a text-* color). The deeper a box sinks, the more it takes on. No tint when unset. |
canvasClassName | string | None | Classes for the canvas, e.g. an opacity or a mask that fades the wall out at the edges. |
className | string | None | Classes for the root, which sizes the wall. It clips the canvas, so give it the rounded corners. |
childrenType
React.ReactNodeContent laid over the wall. The pointer still presses the boxes under it.
cellType
numberDefault48Size of one box, in px.
gapType
numberDefault2Width of the seam around each box, in px.
0gives one seamless surface until it's pressed.depthType
numberDefault48How far the boxes under the pointer sink, in px. Holding the pointer down pushes them 60% further.
reachType
numberDefault140Radius of the dent around the pointer, in px.
falloffType
numberDefault0.45Shape of the dent, from
0(one flat floor with a sharp rim) to1(a smooth bowl that terraces down).tiltType
numberDefault0.35How far above the wall the viewer sits. Higher shows more of the walls;
0looks straight on, with only the perspective showing them.perspectiveType
numberDefault900Distance from the viewer to the wall, in px. Lower makes sunk boxes shrink more and their side walls show further from the center.
rippleType
booleanDefaulttrueRoll a ring out across the wall from every press.
sinkStiffnessType
numberDefault340Stiffness of the spring a box sinks on. Higher snaps the dent in under the pointer faster.
riseStiffnessType
numberDefault64Stiffness of the spring a box rises back on. Lower fills the dent in more slowly, so the pointer leaves a longer trail.
riseDampingType
numberDefault0.62Damping ratio of the rise. Under
1a box bobs just past the surface before it settles;1and up comes back without the bob.rippleSpeedType
numberDefault820How fast the ring from a press rolls out across the wall, in px per second.
boxClassNameType
stringDefault"text-zinc-100 dark:text-zinc-900"Classes that set the boxes' color (a
text-*color). Seams, walls and the floor are shaded from it, lighter seams on a dark color and darker ones on a light color.accentClassNameType
stringClasses that tint the floor of the dent (a
text-*color). The deeper a box sinks, the more it takes on. No tint when unset.canvasClassNameType
stringClasses for the canvas, e.g. an opacity or a mask that fades the wall out at the edges.
classNameType
stringClasses for the root, which sizes the wall. It clips the canvas, so give it the rounded corners.