Kipachu
Guides

Pointerdown vs Click in Animated UI

Why click fires late in the input event sequence, what that means for animated UI, and the pointerdown + detail-aware click pattern that handles it without breaking keyboard activation.

Pointerdown vs Click in Animated UI

When a user clicks a button, the browser fires more than one event. The order matters. The element at the moment of click is not necessarily the element at the moment of pointerdown — and that gap is where animated UI breaks.

Overview

The Pointer Events specification defines a unified model for mouse, touch, and pen input. Each interaction is a sequence of events, not a single event:

pointerdown → mousedown → focus changes → layout/paint → pointerup → mouseup → click

The browser does default activation work between pointerdown and click. For a plain button, that work is invisible. For an element that animates, re-renders, or moves between press and release, that work becomes visible — as flicker, missed clicks, or wrong target.

This guide explains what runs in the gap, why animated UI breaks, and how to handle pointer activation while preserving keyboard activation.

The input event sequence

When a user presses the primary mouse button on an element, the browser fires the following events roughly in order:

1. pointerdown       — pointer becomes active (finger / button / stylus)
2. mousedown         — mouse-specific activation event (when applicable)
3. focus changes     — element receives focus, :focus-visible applies
4. style/layout/paint — browser runs default activation (focus ring, active state)
5. pointerup         — pointer becomes inactive
6. mouseup           — mouse-specific deactivation
7. click             — final semantic activation event

Step 4 is the part most developers forget. Between press and release, the browser may:

  • Move focus to the element.
  • Apply :focus-visible styles.
  • Run :active or layout transition styles.
  • Commit any state changes queued by the focus shift (e.g. CSS variables, ARIA states).
  • Paint one or more intermediate frames.

By the time click fires, all of that may have happened.

Why click fires late

The click event is defined as a completed activation — it fires only after press and release complete on the same element. The browser checks:

  • The mousedown target and the mouseup target must be the same element (or a related control like a label).
  • The pointer did not move beyond a small drag threshold between down and up.
  • The press and release were not canceled (e.g. by pointercancel).

If any of those fail, click is silently dropped. There is no error, no fallback, no event you can listen for.

Why animated UI breaks

Three things can change between pointerdown and click:

1. The click target changes

If a hover state expands a row, a re-render replaces a child, or Motion animates layout, the element under the pointer at pointerup may not be the same element that received the pointerdown. The click either fires on a different element (the parent, a sibling, the background) or is dropped entirely.

// The row at pointerdown is the row at pointerup only if nothing in between
// committed a render. Motion's `layout` animation commits a render.
<motion.li layout whileHover={{ scale: 1.05 }} onClick={...}>

2. The browser paints an intermediate state

Chrome is eager to commit and paint state changes triggered by the press. If :focus-visible or :active styles cause a visual change, that change is painted between pointerdown and click.

Safari defers and coalesces these paints. The state may still change internally, but the user does not see the intermediate frame.

Firefox behavior depends on the platform and the type of state change.

This is why the same code can flicker on Chrome and look smooth on Safari. It is a paint-scheduling difference, not a correctness difference.

3. A parent captures the pointer

Libraries that handle drag, swipe, or pan gestures call setPointerCapture during the press. Once a parent captures the pointer, the original target never sees pointerup and therefore never sees click. Motion, Radix, and Vaul do this internally.

This is the well-known motion#252 bug — buttons inside motion.div drag ignore clicks on iPad.

The robust pattern

The fix is to:

  1. Handle pointer activation early, on pointerdown.
  2. Suppress the default mouse activation work so the browser does not paint an intermediate state.
  3. Keep onClick for keyboard activation, but disambiguate from pointer-fired clicks using event.detail.
<button
  type="button"
  onPointerDown={(event) => {
    if (event.button !== 0) return  // primary button only
    event.preventDefault()          // suppress default activation
    activate()
  }}
  onClick={(event) => {
    if (event.detail !== 0) return  // keyboard-fired click only
    activate()
  }}
>
  {label}
</button>

What each flag does

  • event.button !== 0 filters pointerdown to primary-button activation. Right-click (button === 2) and middle-click (button === 1) skip the handler. This is what users expect — right-click should open a context menu, not trigger the action.
  • event.preventDefault() suppresses the default mouse activation work the browser does between pointerdown and click. This is the part that removes the Chrome flicker. Without it, moving the action earlier fixes the missed click but Chrome may still paint an intermediate frame.
  • event.detail !== 0 on click distinguishes pointer-generated clicks (detail === 1) from keyboard-generated clicks (detail === 0). The check ignores the pointer-fired click — already handled — and only handles keyboard activation.

Why this preserves accessibility

A naive fix moves the action to onPointerDown and removes onClick. That breaks keyboard users — Enter and Space on a focused button fire click, not pointerdown. Native button semantics depend on click.

The pattern above keeps both handlers and uses event.detail to ensure no path runs twice:

  • Mouse user: pointerdownactivate(); click ignored (detail === 1).
  • Touch user: same as mouse.
  • Keyboard user (Enter / Space): clickactivate() (detail === 0).

Screen readers and assistive technologies interact with controls through semantic activation, not physical pointer events. They see the click event regardless of how it was synthesized.

Pattern variations

The pattern above is the canonical form. Adapt it to the specific UI:

Toggleable items (checkboxes, switches, pills)

<button
  type="button"
  role="switch"
  aria-checked={on}
  onPointerDown={(event) => {
    if (event.button !== 0) return
    event.preventDefault()
    setOn((v) => !v)
  }}
  onClick={(event) => {
    if (event.detail !== 0) return
    setOn((v) => !v)
  }}
>
  {label}
</button>

List items in an animated list

<motion.li
  layout
  onPointerDown={(event) => {
    if (event.button !== 0) return
    event.preventDefault()
    select(item.id)
  }}
  onClick={(event) => {
    if (event.detail !== 0) return
    select(item.id)
  }}
>
  {item.name}
</motion.li>

Slider / scrubber

For drag-style controls, use the parent's onPointerDown and continue tracking on pointermove. The event.detail check on click is not needed because click is not the activation event.

<div
  role="slider"
  tabIndex={0}
  onPointerDown={(event) => {
    if (event.button !== 0) return
    event.preventDefault()
    target.setPointerCapture(event.pointerId)
    setDragging(true)
  }}
  onPointerMove={(event) => {
    if (!dragging) return
    updateValue(event.clientX)
  }}
  onPointerUp={() => setDragging(false)}
>
  ...
</div>

Anti-patterns

  • Don't move everything from onClick to onPointerDown and delete onClick. Breaks keyboard.
  • Don't skip the event.detail check. Mouse-generated clicks have detail === 1; without the check, mouse activation runs twice.
  • Don't skip event.preventDefault(). Without it, Chrome may still flicker between pointerdown and the eventual action.
  • Don't use this pattern for destructive actions. A user may press down, drag away, and expect cancellation. click is the right event for destructive actions — it represents completed activation.
  • Don't use mousedown instead of pointerdown for general UI. mousedown is mouse-only; pointerdown unifies mouse, touch, and pen.

When to keep plain onClick

Use plain onClick when:

  • The element does not animate between press and release.
  • The element does not re-render or change size during the press.
  • The element is not inside a parent that calls setPointerCapture.

Most form controls, links, and static buttons fit this. Reserve the pointerdown pattern for elements where the visual state is unstable during interactive input — animated rows, expanding menus, virtualized lists, drag-and-drop targets.

Testing

To verify the pattern works:

  1. Mouse: click should fire activate() once on press. Watch the console for a duplicate log.
  2. Touch: tap should fire activate() once. Drag-then-release outside the element should not activate.
  3. Keyboard: focus the button, press Enter or Space. activate() should fire once.
  4. Right-click: should not fire activate().
  5. Mid-press re-render: render a state change during the press (e.g. via a useState setter) and verify click does not drop.

API reference

event.button (PointerEvent, MouseEvent)

  • 0 — primary button (left mouse, single-finger touch).
  • 1 — auxiliary button (middle mouse).
  • 2 — secondary button (right mouse).
  • 3 — browser back.
  • 4 — browser forward.

Use event.button !== 0 to restrict handlers to primary-button activation.

event.detail (MouseEvent)

  • 0 — synthesized from keyboard or programmatic invocation.
  • 1 — single mouse click.
  • 2+ — repeated clicks in quick succession (used by browser to detect double-click).

Use event.detail !== 0 on click to skip pointer-fired clicks when both handlers are present.

event.preventDefault() (PointerEvent)

On pointerdown, suppresses the default mouse activation work the browser performs. Removes intermediate focus / active / layout-state paints between press and release.

Call only when you handle the activation yourself — do not call on every pointerdown.

Mental model

pointerdown = "the user physically started pressing here"
click       = "the browser decided a full activation happened"

For stable UI, click is the right event.

For animated UI where the target can change before activation completes, pointerdown (with preventDefault) gives early response; click (with detail === 0 filter) preserves keyboard activation.