## 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 (
);
}
```