Any surface sinks, drops a pixel and flattens its shadow, then springs back.
Motion primitivesmotion
01Preview
Choose a starting point
You can change the framework later in project settings.
Hold ⌘ Enter to continue
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { PressDepth } from "@/components/ui/press-depth";
<PressDepth className="rounded-xl">
<button className="rounded-xl border border-line-2 bg-raised p-3">Next.js app</button>
</PressDepth>
// Hold it down from a shortcut, the way a key press would.
<PressDepth depth="deep" pressed={shortcutHeld || undefined} className="rounded-lg">
<button className="h-8 rounded-lg bg-fg px-3 text-frame">Continue</button>
</PressDepth>04Source
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
/* -------------------------------------------------------------------------------------------------
* usePress: is this surface being held down right now?
* -----------------------------------------------------------------------------------------------*/
const BLOCKED = ':disabled, [aria-disabled="true"], [data-disabled], [data-press-ignore]';
// What each key activates natively. Enter never presses a checkbox; Space never follows a link.
const ON_ENTER = 'button, a[href], summary, input[type="button"], input[type="submit"], input[type="reset"], [role="button"], [role="link"], [role="menuitem"], [role="tab"]';
const ON_SPACE = 'button, summary, input[type="button"], input[type="submit"], input[type="reset"], input[type="checkbox"], input[type="radio"], [role="button"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"]';
export type UsePressOptions = {
/** Ignore every press. */
disabled?: boolean;
onPressedChange?: (pressed: boolean) => void;
};
/**
* Tracks whether a surface is held by a pointer or a key. Dragging off lets go and
* dragging back on presses again, the same way a native button decides whether
* the release counts as a click. A touch that becomes a scroll lets go at once.
*/
export function usePress(ref: React.RefObject<HTMLElement | null>, { disabled = false, onPressedChange }: UsePressOptions = {}) {
const [pressed, setPressed] = useState(false);
const notify = useRef(onPressedChange);
useEffect(() => {
notify.current = onPressedChange;
});
useEffect(() => {
const el = ref.current;
if (!el || disabled) return;
let current = false;
const set = (next: boolean) => {
if (next === current) return;
current = next;
setPressed(next);
notify.current?.(next);
};
const owns = (target: EventTarget | null) => {
if (!(target instanceof Element) || !el.contains(target)) return false;
if (target.closest(BLOCKED)) return false;
// A pressable surface nested inside this one takes the press for itself.
const nearest = target.closest("[data-press-depth]");
if (nearest && nearest !== el && el.contains(nearest)) return false;
return true;
};
/* Pointer: armed from down to up, pressed only while over the surface. */
let armed = false;
const disarm = () => {
armed = false;
set(false);
window.removeEventListener("pointerup", disarm);
window.removeEventListener("pointercancel", disarm);
};
const onDown = (e: PointerEvent) => {
if (e.button !== 0 || !owns(e.target)) return;
armed = true;
set(true);
window.addEventListener("pointerup", disarm);
window.addEventListener("pointercancel", disarm);
};
const onLeave = () => armed && set(false);
const onEnter = (e: PointerEvent) => armed && e.buttons & 1 && set(true);
/* Keyboard: held from keydown to keyup of the key that activates the focused control. */
let key: string | null = null;
const onKeyDown = (e: KeyboardEvent) => {
if (e.repeat || key || e.metaKey || e.ctrlKey || e.altKey || !owns(e.target)) return;
const target = e.target as Element;
if ((e.key === "Enter" && target.matches(ON_ENTER)) || (e.key === " " && target.matches(ON_SPACE))) {
key = e.key;
set(true);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key !== key) return;
key = null;
set(false);
};
const onFocusOut = () => {
key = null;
if (!armed) set(false);
};
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("keydown", onKeyDown);
el.addEventListener("keyup", onKeyUp);
el.addEventListener("focusout", onFocusOut);
return () => {
disarm();
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("keydown", onKeyDown);
el.removeEventListener("keyup", onKeyUp);
el.removeEventListener("focusout", onFocusOut);
};
}, [ref, disabled]);
return disabled ? false : pressed;
}
/* -------------------------------------------------------------------------------------------------
* PressDepth
* -----------------------------------------------------------------------------------------------*/
export type PressDepthDepth = "subtle" | "default" | "deep";
export type PressDepthProps = Omit<
React.ComponentProps<"div">,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> & {
/** How far the surface's edges travel in: about 1.25px, 2px or 3px, whatever its size. */
depth?: PressDepthDepth;
/** Draws the surface's shadow on the wrapper so the press can flatten it. Use "flat" for surfaces with no shadow. */
elevation?: "raised" | "flat";
/** Controls the press: true holds it down (e.g. while its shortcut is held), false holds it up. Leave unset to follow the pointer and keys. */
pressed?: boolean;
/** Called when a pointer or key presses or releases the surface. */
onPressedChange?: (pressed: boolean) => void;
/** Stops the press. Targets that are disabled or inside `[data-press-ignore]` never press it either. */
disabled?: boolean;
};
const travel: Record<PressDepthDepth, { edge: number; drop: number }> = {
subtle: { edge: 1.25, drop: 0.5 },
default: { edge: 2, drop: 1 },
deep: { edge: 3, drop: 1.5 },
};
// Quick in, sprung out. The release overshoots by well under a pixel: felt, not seen.
const pressIn = { duration: 0.09, ease: ease.out };
const release = { type: "spring", stiffness: 500, damping: 21, mass: 0.6 } as const;
/**
* Gives whatever it wraps a physical press: it sinks, drops a pixel and its
* shadow flattens, then springs back on release. Scale is worked out from the
* surface's size so a chip and a wide card move their edges the same distance.
*/
export function PressDepth({
depth = "default",
elevation = "raised",
pressed: pressedProp,
onPressedChange,
disabled = false,
className,
children,
ref,
...rest
}: PressDepthProps) {
const el = useRef<HTMLDivElement | null>(null);
const reduce = !!useReducedMotion();
const [size, setSize] = useState(0);
const tracked = usePress(el, { disabled, onPressedChange });
// Controlled when `pressed` is set; otherwise it follows the pointer and keys.
const pressed = !disabled && (pressedProp ?? tracked);
const setRef = useCallback(
(node: HTMLDivElement | null) => {
el.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
},
[ref],
);
// The untransformed size, kept current so the press never measures in the hot path.
useEffect(() => {
const node = el.current;
if (!node) return;
const ro = new ResizeObserver(([entry]) => {
const box = entry.borderBoxSize?.[0];
setSize(Math.max(box?.inlineSize ?? node.offsetWidth, box?.blockSize ?? node.offsetHeight));
});
ro.observe(node);
return () => ro.disconnect();
}, []);
const { edge, drop } = travel[depth];
const scale = size ? Math.min(0.992, Math.max(0.94, 1 - (edge * 2) / size)) : 0.97;
const move = reduce ? { scale: 1, y: 0 } : pressed ? { scale, y: drop } : { scale: 1, y: 0 };
return (
<motion.div
ref={setRef}
data-press-depth=""
data-pressed={pressed ? "" : undefined}
data-disabled={disabled ? "" : undefined}
className={cn("relative isolate [-webkit-tap-highlight-color:transparent]", className)}
initial={false}
animate={move}
transition={pressed ? pressIn : release}
{...rest}
>
{elevation === "raised" && (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 rounded-[inherit] shadow-[var(--shadow)]"
initial={false}
animate={{ opacity: pressed ? 0.3 : 1 }}
transition={pressed ? { duration: 0.09, ease: "linear" } : { duration: 0.26, ease: ease.out }}
/>
)}
{children}
{/* Without movement, the press still has to register: a faint wash while held.
Always in the tree (hidden unless reduced) so server and client markup match. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] bg-fg/[0.05] transition-opacity",
reduce && pressed ? "opacity-100 duration-75" : "opacity-0 duration-200",
)}
/>
</motion.div>
);
}05Props
PressDepth
| Prop | Type | Default | Description |
|---|---|---|---|
| depth | "subtle" | "default" | "deep" | "default" | How far the edges travel in: about 1.25px, 2px or 3px, whatever the surface's size, plus a 0.5–1.5px drop. |
| elevation | "raised" | "flat" | "raised" | Raised draws the surface's shadow on the wrapper so the press can flatten it. Flat for surfaces without one. |
| pressed | boolean | — | Controls the press: true holds it down, false holds it up. Leave unset to follow the pointer and keys. |
| onPressedChange | (pressed: boolean) => void | — | Called when a pointer or key presses or releases the surface. |
| disabled | boolean | false | Stops the press. Disabled targets inside, and anything under [data-press-ignore], never press it either. |
| className | string | — | Merged onto the wrapper. Give it the child's radius so the shadow and reduced-motion wash follow the corners. |
usePress
| Prop | Type | Default | Description |
|---|---|---|---|
| ref* | RefObject<HTMLElement | null> | — | The surface to watch. Returns whether it is held right now. |
| disabled | boolean | false | Ignore every press. |
| onPressedChange | (pressed: boolean) => void | — | Called on each change of the held state. |
06Notes
Behavior
- The scale is worked out from the surface's measured size, so a 90px button and a 400px card move their edges by the same few pixels instead of the card lurching.
- Dragging off lets go and dragging back on presses again, matching whether the release would count as a click; a touch that turns into a scroll releases at once.
- Keys press only what they would activate: Enter on buttons and links, Space on buttons, checkboxes and radios. Key repeat and modifier chords are ignored.
- Nested PressDepth surfaces take the press for themselves, so a button inside a pressable card doesn't sink the card too.
Motion
- Press goes in over 90ms on the expo ease-out, shadow dropping to 30%; release comes back on a 500/21 spring that overshoots well under a pixel, felt rather than seen.
- The shadow recovers over 260ms, a beat slower than the surface, so the surface seems to rise off the page.
- Reduced motion keeps the surface still and lays a 5% foreground wash over it while held, so the press still registers.
Accessibility
- Adds no role, no tab stop and no handlers to the child: the button, link or radio inside keeps its own semantics and click.
- The shadow and wash layers are aria-hidden and pointer-events: none.
- Holding Enter or Space on the focused control shows the same press as a pointer.