## Press Depth — Action Feedback The feeling that the press landed. Docs: https://www.interior.dev/docs/press-depth Reference: https://www.interior.dev/reference/press-depth License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/press-depth.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { PressDepth, usePressDepth } from "@/components/interior/press-depth"; export function AmountPad({ onSubmit }: { onSubmit: (cents: number) => void }) { const [digits, setDigits] = useState(""); const { pressed, ref, bind } = usePressDepth(); return (

${(Number(digits || "0") / 100).toFixed(2)}

{["1", "2", "3"].map((d) => ( setDigits((v) => (v + d).slice(0, 6))} > {d} ))}
); } ``` ### Props - `children` (`React.ReactNode`) Label content for the key face. - `depth` (`number`) — default: `2` Travel in pixels. The wrapper reserves this space as bottom padding before the first press, so the key never changes footprint. - `disabled` (`boolean`) — default: `false` Blocks the gesture and releases any press already in flight. - `type` (`"button" | "submit" | "reset"`) — default: `"button"` Forwarded to the underlying button so the key works inside a form. - `onClick` (`React.MouseEventHandler`) Native click. Activation is left to the browser, so Enter, Space, and release-outside behave exactly as they do on a plain button. - `className` (`string`) — default: `""` Appended last to the key face, so surface, padding, and type are overridable. - `aria-label` (`string`) Accessible name for icon-only keys. - `usePressDepth(options)` (`(options?: UsePressDepthOptions) => UsePressDepthResult`) Returns { pressed, ref, bind } for drawing your own surface. Options are disabled, onPressStart, onPressEnd. ### Behavior notes - The key reserves its travel as bottom padding before the first press, so depressing it moves a transform and never the layout around it. - Press state is tracked on the window rather than the element, so a pointer that leaves the key mid-hold lifts it, and a pointer that comes back presses it again — the visual state and the browser's own click suppression agree. - A press that is interrupted by a scroll, a tab switch, a window blur, or a disabled prop arriving mid-hold releases instead of sticking down forever. - Activation stays with the browser: Enter and Space fire a real click on a real button, so no synthetic handler double-fires and no keyboard path is invented. - Auto-repeat is ignored, so holding Enter presses once instead of hammering the key sixty times a second. - Under prefers-reduced-motion the key still lands at full depth, instantly — the confirmation survives, only the spring is dropped. ### Source (`components/interior/press-depth.tsx`) ```tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const PRESS = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; export type UsePressDepthOptions = { disabled?: boolean; onPressStart?: () => void; onPressEnd?: () => void; }; export type PressOrigin = { x: number; y: number }; export type UsePressDepthResult = { pressed: boolean; origin: PressOrigin | null; ref: (node: HTMLElement | null) => void; bind: { onPointerDown: (event: React.PointerEvent) => void; onKeyDown: (event: React.KeyboardEvent) => void; onKeyUp: (event: React.KeyboardEvent) => void; onBlur: () => void; }; }; export function usePressDepth( options: UsePressDepthOptions = {}, ): UsePressDepthResult { const { disabled = false, onPressStart, onPressEnd } = options; const [pressed, setPressed] = useState(false); const [tracking, setTracking] = useState(false); const [origin, setOrigin] = useState(null); const node = useRef(null); const pointer = useRef(null); const down = useRef(false); const began = useRef(onPressStart); began.current = onPressStart; const ended = useRef(onPressEnd); ended.current = onPressEnd; const setDown = useCallback((next: boolean) => { if (down.current === next) return; down.current = next; setPressed(next); if (next) began.current?.(); else ended.current?.(); }, []); const stop = useCallback(() => { pointer.current = null; setTracking(false); setOrigin(null); setDown(false); }, [setDown]); useEffect(() => { if (!tracking) return; const contains = (event: PointerEvent) => { const el = node.current; if (!el) return false; const r = el.getBoundingClientRect(); return ( event.clientX >= r.left && event.clientX <= r.right && event.clientY >= r.top && event.clientY <= r.bottom ); }; const move = (event: PointerEvent) => { if (event.pointerId !== pointer.current) return; setDown(contains(event)); }; const lift = (event: PointerEvent) => { if (event.pointerId !== pointer.current) return; stop(); }; const bail = () => stop(); const hidden = () => { if (document.hidden) stop(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", lift); window.addEventListener("pointercancel", lift); window.addEventListener("blur", bail); document.addEventListener("visibilitychange", hidden); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", lift); window.removeEventListener("pointercancel", lift); window.removeEventListener("blur", bail); document.removeEventListener("visibilitychange", hidden); }; }, [tracking, setDown, stop]); useEffect(() => { if (disabled) stop(); }, [disabled, stop]); const ref = useCallback((next: HTMLElement | null) => { node.current = next; }, []); const bind = { onPointerDown: (event: React.PointerEvent) => { if (disabled) return; if (event.pointerType === "mouse" && event.button !== 0) return; const r = event.currentTarget.getBoundingClientRect(); setOrigin({ x: Math.max(-1, Math.min(1, ((event.clientX - r.left) / r.width) * 2 - 1)), y: Math.max(-1, Math.min(1, ((event.clientY - r.top) / r.height) * 2 - 1)), }); pointer.current = event.pointerId; setTracking(true); setDown(true); }, onKeyDown: (event: React.KeyboardEvent) => { if (disabled || event.repeat) return; if (event.key === " " || event.key === "Enter") setDown(true); }, onKeyUp: (event: React.KeyboardEvent) => { if (event.key === " " || event.key === "Enter" || event.key === "Escape") { setDown(false); } }, onBlur: () => stop(), }; return { pressed, origin, ref, bind }; } export type PressDepthProps = { children: React.ReactNode; depth?: number; tilt?: number; disabled?: boolean; type?: "button" | "submit" | "reset"; onClick?: React.MouseEventHandler; className?: string; "aria-label"?: string; }; export function PressDepth({ children, depth = 4, tilt = 7, disabled = false, type = "button", onClick, className = "", "aria-label": ariaLabel, }: PressDepthProps) { const reduced = useReducedMotion(); const { pressed, origin, ref, bind } = usePressDepth({ disabled }); const lean = pressed && origin && !reduced ? origin : null; return ( ); } ```