Tap & gesture feedback
Touch interfaces lack physical clicks, so a tap needs visible feedback — a ripple and a small press — to confirm the surface felt you.
In one line: a touchscreen gives no click and no travel, so a tap needs a visible answer — a ripple from your fingertip and a small press-in — to say the surface felt you.
What it is
Tap feedback is the visible reply a control gives the instant you touch it. On a physical button you feel the give and hear the click; a glass screen offers neither, so the interface has to draw the sensation instead. Two moves carry most of the weight: a ripple that grows outward from the exact point you pressed, and a press — the surface scaling down a hair as if your finger pushed it in, then recovering when you let go.
The ripple's job is location: it confirms which spot registered, starting where your fingertip landed rather than at some fixed center. The press's job is force: it echoes the act of pushing. Together they turn a flat state change into a small physical event.
Why it matters
On touch there's a gap between contact and result — the network call, the route change, the sheet that slides up. Without immediate feedback, that gap reads as did it even work?, and people tap again. A ripple and a press close the gap instantly: the surface answers in the first frame, before the real work finishes, so the tap feels acknowledged even when the outcome is still loading.
It also builds trust through precision. A ripple that springs from your fingertip says the interface saw exactly where you touched — not a vague region, that point. The restraint matters as much as the effect: a slow ripple that lingers, or a press so deep the control visibly shrinks, reads as gimmicky and adds lag to every interaction. The feedback should be quick and almost subliminal — felt, then gone before you think about it.
See it
Tweak it2
How it works
Four moves, and one fallback:
- Origin at the pointer. On pointer-down, read the press coordinates relative to the surface (
clientX/Yminus the element's bounding rect) and place the ripple there. That single offset is what makes it feel located rather than generic. - Clip to the control. The ripple is a circle that grows past the surface edges, so the surface needs
overflow: hidden. Without it the circle bleeds out and the effect falls apart. - Expand and fade. Animate the ripple from
scale: 0up and from a low opacity down to zero in one short pass. Onlytransformandopacitymove, so it stays on the compositor. - Press and recover. On press, scale the whole surface down a few percent; on release, spring it back. The recovery is a spring so it feels like a physical rebound, not a scripted reset.
- Keyboard fallback. A keyboard user has no pointer coordinates, so on Space/Enter center the ripple in the surface. The press and recovery are identical — the interaction shouldn't feel second-class on a keyboard.
Build it
.tap-surface {
position: relative;
overflow: hidden; /* clip the ripple to the control */
transform: scale(1);
transition: transform 120ms ease;
}
/* press in while held; recovery is the transition above */
.tap-surface:active {
transform: scale(0.97);
}
/* one ripple element, positioned by JS at the press point */
.ripple {
position: absolute;
width: 12rem;
height: 12rem;
margin: -6rem 0 0 -6rem; /* center the circle on its x/y */
border-radius: 9999px;
background: rgba(255, 255, 255, 0.3);
transform: scale(0);
opacity: 0.5;
pointer-events: none;
animation: ripple 600ms ease-out forwards;
}
@keyframes ripple {
to {
transform: scale(2.6);
opacity: 0;
}
}
/* JS: on pointerdown, place + spawn a .ripple, then remove it on animationend
const r = el.getBoundingClientRect();
ripple.style.left = (e.clientX - r.left) + "px";
ripple.style.top = (e.clientY - r.top) + "px";
For keyboard (Space/Enter), use the surface center: r.width / 2, r.height / 2. */
@media (prefers-reduced-motion: reduce) {
.tap-surface,
.tap-surface:active {
transition: none;
transform: none;
}
.ripple {
animation: none;
display: none; /* calm, instant highlight instead */
}
}import { useRef, useState } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
export function TapSurface({ children }) {
const reduce = useReducedMotion();
const [ripples, setRipples] = useState([]);
const [pressed, setPressed] = useState(false);
const id = useRef(0);
const ref = useRef(null);
// Spawn a ripple at a point inside the surface (skipped under reduced motion).
const spawn = (x, y) => {
if (reduce) return;
setRipples((rs) => [...rs, { id: id.current++, x, y }]);
};
return (
<motion.button
ref={ref}
type="button"
onPointerDown={(e) => {
setPressed(true);
const r = e.currentTarget.getBoundingClientRect();
spawn(e.clientX - r.left, e.clientY - r.top); // origin at the pointer
}}
onPointerUp={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && setPressed(true)}
onKeyUp={(e) => {
if (e.key !== " " && e.key !== "Enter") return;
setPressed(false);
const el = ref.current; // keyboard has no pointer → center the ripple
if (el) spawn(el.clientWidth / 2, el.clientHeight / 2);
}}
animate={reduce ? { scale: 1 } : { scale: pressed ? 0.97 : 1 }}
transition={{ type: "spring", stiffness: 520, damping: 30 }}
style={{ position: "relative", overflow: "hidden" }} // clip the ripple
className="tap-surface"
>
{children}
{!reduce && (
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
aria-hidden
style={{
position: "absolute",
left: r.x,
top: r.y,
width: 160,
height: 160,
marginLeft: -80,
marginTop: -80,
borderRadius: 9999,
background: "rgba(255,255,255,0.3)",
pointerEvents: "none",
}}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale: 2.6, opacity: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
// remove the node once it has faded, so they don't pile up
onAnimationComplete={() =>
setRipples((rs) => rs.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
)}
</motion.button>
);
}Make it yours
Use the controls beside the demo above to change show ripple and press depth — each change updates the example live.
Experiment in the playgroundReproduce it with an LLM
Reproduce it with an LLM
You are a front-end engineer building for touch and pointer. Add tactile feedback to a button: on press it shows a brief radius-bounded ripple originating at the pointer position and a subtle scale-down, recovering on release. Build it in React with the Motion library. Requirements: it must be a real <button> with keyboard activation (the ripple may center itself for keyboard activation); a visible focus-visible ring; the ripple must be clipped to the button (overflow hidden) and animate only transform/opacity; fully disable the ripple and scale under prefers-reduced-motion: reduce. No external CSS framework. Return only the component code with imports.
Pitfalls & accessibility
Related
Jelly button: squash & stretch on press
Make a control feel physical: it squashes when pressed and springs back with a wobble.
Anatomy of a micro-interaction
Every small feedback moment has four parts — trigger, rules, feedback, and loops. Name them and you can design any of them on purpose.
Motion principles
Borrowed from classic animation: timing, anticipation, follow-through, and staging turn a mechanical move into one that reads as intentional.
Further reading
- Your animation library's gesture and
AnimatePresencedocs, for spawning and unmounting transient elements like ripples cleanly. - The pointer events spec, for handling touch, pen, and mouse through one unified set of handlers.