Interactive6 min read

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 soft white button pressed into a cushioned surface, with fine ripples spreading outward and a lavender dot at its centre.
Generated for Ainur Design

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.

Six identical buttons with different press treatments, plus a toggle, a detent slider and a save button that use haptics. The pill at the top shows each haptic, so the demo also reads on a desktop.

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.

TreatmentWhat it saysUse it forWatch out for
Scale to about 0.97"You pushed this object"Primary buttons, cards, tilesScaling 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 itemsLow-contrast states on light surfaces; check the pressed label still reads
Highlight or tint"This row is selected"List rows, menu items, full-width cellsUsing the same tint as the selected state, which confuses pressed with chosen
NoneNothingNever for something tappableUsers 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, .rigid and .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, .warning or .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.

ConstantMeaningAPI level
KEYBOARD_TAPA key or soft button press8
CLOCK_TICKA tick, as on a time picker21
CONFIRMAn action succeeded30
REJECTAn action failed or was refused30
GESTURE_START / GESTURE_ENDA gesture began or finished30
TOGGLE_ON / TOGGLE_OFFA switch changed state34
SEGMENT_TICKA slider or segmented control moved one step34

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

  1. Playing haptics · Apple Human Interface Guidelines
  2. sensoryFeedback(_:trigger:) · Apple Developer
  3. UIImpactFeedbackGenerator · Apple Developer
  4. Add haptic feedback to events · Android Developers
  5. HapticFeedbackConstants · Android Developers
  6. HapticFeedbackType · Android Developers
  7. State layers · Material Design
  8. Navigator: vibrate() method · MDN Web Docs

Keep your place in the app

Save this story, pick up where you left off and follow Sana.

Coming soon onGoogle PlayComing soon onApp Store