What a tap should feel like: press states and haptics
Why every tappable element needs a pressed state in the first frame, which treatment to use where, and the small haptic vocabulary iOS and Android give you.

A tap is a question: did that work? The interface has to answer before the person's finger leaves the glass. If the button looks the same while pressed, people press harder, tap again, or assume the app froze. Double submissions and the vague sense that an app feels cheap can start in this one missing frame.
This piece covers the two parts of that answer: the visual pressed state, which every tappable thing needs, and haptics, which a few moments deserve. We treat haptics as a design material with a small, strict vocabulary, not as decoration.
Things to try in the demo:
- Press and hold each Continue button. Notice how the one with no feedback feels broken, even though it works.
- Press Late press quickly. Its pressed state arrives 120 ms after your finger, which is how a press state feels when it waits for a gesture recogniser or a JavaScript round trip. Short taps often show no feedback at all.
- Drag the Reminder slider. You get one light tick per 5-minute detent and a firmer one at each end, never a buzz in between.
- Turn the Haptics row off. Every control still works and still shows its state; only the haptic is skipped. That is the behaviour you want when someone disables haptics in system settings.
- On an Android phone the demo uses real haptics inside the Ainur app, or the browser's vibration API in Chrome. iOS Safari has no web vibration API, and browsers block it in cross-origin frames, so the indicator pill is there to show what would fire.
The pressed state belongs in the first frame
Show the pressed state on touch-down, not on release, and show it in the very next frame. At 60 Hz that is within about 16.7 ms; on a 120 Hz screen, about 8.3 ms. Anything you put between the touch and the visual, such as a network check, a gesture delay or a heavy re-render, shows up as lag.
The asymmetry matters as much as the speed. Press-in is instant: no transition, the state just appears. Release is animated: a short return of around 150 to 200 ms, which lets the eye see the button come back. Animate the press-in and the button feels soft and late. Snap the release and the button flickers.
.button {
transition: transform 180ms cubic-bezier(0.2, 0, 0, 1),
background-color 180ms linear;
}
/* Press-in: no transition, so it lands in the next frame */
.button.is-pressed {
transform: scale(0.97);
transition-duration: 0s;
}
One more rule: cancel the pressed state when the finger slides off the control, and do not fire the action. People use that slide-off to change their mind.
Scale, opacity or highlight
All three work. They suit different components, and mixing them randomly in one app is what makes an interface feel inconsistent.
| Treatment | What it says | Use it for | Watch out for |
|---|---|---|---|
| Scale to about 0.97 | "You pushed this object" | Primary buttons, cards, tiles | Scaling text-heavy rows; keep it subtle so layout doesn't appear to shift |
| Opacity to about 0.6 | "This is reacting" | Icon buttons, text links, toolbar items | Low-contrast states on light surfaces; check the pressed label still reads |
| Highlight or tint | "This row is selected" | List rows, menu items, full-width cells | Using the same tint as the selected state, which confuses pressed with chosen |
| None | Nothing | Never for something tappable | Users tap twice and submit twice |
On Android, Material's state layers formalise the highlight approach: a pressed overlay drawn in the content colour at a fixed opacity, plus a ripple from the touch point. On iOS, system buttons dim or highlight. We match the platform for system-style controls and use one consistent treatment, usually scale for buttons and highlight for rows, for custom components.
Haptics confirm, they never decorate
Apple's Human Interface Guidelines say to use haptics consistently, to complement other feedback, to avoid overusing them and to make them optional. Our version is shorter: a haptic must confirm something the person did or something that happened to their data. If you cannot say what it confirms, delete it.
Good moments: a toggle changing state, a picker or slider crossing a detent, a drag snapping into place, a payment or save succeeding, an action failing. Bad moments: every button tap, every scroll, page transitions, decorative animations, anything that repeats faster than a person can distinguish.
The iOS vocabulary
UIKit has three feedback generators, each with a specific meaning. Apple asks that you use system patterns according to their documented meanings rather than borrowing them for something else.
UIImpactFeedbackGenerator: a physical collision. Styles are.light,.medium,.heavy,.rigidand.soft. Use it when something snaps or lands.UISelectionFeedbackGenerator: a value changing. One tick per step of a picker or detent.UINotificationFeedbackGenerator: the outcome of a task, as.success,.warningor.error.
In SwiftUI on iOS 17 and later, sensoryFeedback(_:trigger:) plays feedback when a value changes, which ties the haptic to state instead of to a gesture handler. The SensoryFeedback type includes .success, .warning, .error, .selection, .impact, .increase and .decrease, among others.
Toggle("Notifications", isOn: $notificationsOn)
.sensoryFeedback(.selection, trigger: notificationsOn)
Button("Save") { save() }
.sensoryFeedback(.success, trigger: saveCount)
The Android vocabulary
On Android, call View.performHapticFeedback() with a HapticFeedbackConstants value. The constants describe purpose, not a vibration pattern, so each device plays what suits its motor. Android's documentation notes this path needs no VIBRATE permission and respects the user's touch feedback setting by default. It also warns that raw one-shot or waveform vibrations are often too strong for regular UI feedback.
| Constant | Meaning | API level |
|---|---|---|
| KEYBOARD_TAP | A key or soft button press | 8 |
| CLOCK_TICK | A tick, as on a time picker | 21 |
| CONFIRM | An action succeeded | 30 |
| REJECT | An action failed or was refused | 30 |
| GESTURE_START / GESTURE_END | A gesture began or finished | 30 |
| TOGGLE_ON / TOGGLE_OFF | A switch changed state | 34 |
| SEGMENT_TICK | A slider or segmented control moved one step | 34 |
Constants that are newer than the device's API level need a guard: check Build.VERSION.SDK_INT and pick an older constant, or play nothing. In Compose, LocalHapticFeedback.current.performHapticFeedback() takes a HapticFeedbackType, and current Compose versions include Confirm, Reject, ToggleOn, ToggleOff, SegmentTick and VirtualKey.
saveButton.setOnClickListener { view ->
val ok = save()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.performHapticFeedback(
if (ok) HapticFeedbackConstants.CONFIRM
else HapticFeedbackConstants.REJECT
)
}
}
Rules we follow
- Every tappable element has a pressed stateButtons, rows, cards, icons, chips. No exceptions for "secondary" controls.
- Pressed state on touch-down, in the next frameInstant press-in, animated release of roughly 150 to 200 ms. Cancel on slide-off.
- One treatment per component typeFor example scale for buttons, highlight for rows, opacity for icons. Write it into the design system.
- Haptics only for state changes, detents and outcomesNever on plain navigation taps or decorative motion.
- Use system haptic APIs, not raw vibrationThey respect user settings and the device's motor. On Android this is performHapticFeedback; on iOS the feedback generators or sensoryFeedback.
- Always pair a haptic with a visual changeThe haptic confirms what the eye already sees. If haptics are off, nothing is lost.
- One haptic per eventNo stacked pulses for one action, no haptic on both press and release unless the control is a key.
Press states are among the cheapest quality signals in an app and among the most often missed. In our reviews we check them screen by screen, with a finger on a real device, because a simulator click hides exactly the lag people feel.
Sources
- Playing haptics · Apple Human Interface Guidelines
- sensoryFeedback(_:trigger:) · Apple Developer
- UIImpactFeedbackGenerator · Apple Developer
- Add haptic feedback to events · Android Developers
- HapticFeedbackConstants · Android Developers
- HapticFeedbackType · Android Developers
- State layers · Material Design
- Navigator: vibrate() method · MDN Web Docs


