Skip to content

fix: Clear touch :hover on a blank tap inside an Android Modal#9878

Merged
MatiPl01 merged 1 commit into
mainfrom
@matipl01/css-hover-modal-clear
Jul 9, 2026
Merged

fix: Clear touch :hover on a blank tap inside an Android Modal#9878
MatiPl01 merged 1 commit into
mainfrom
@matipl01/css-hover-modal-clear

Conversation

@MatiPl01

@MatiPl01 MatiPl01 commented Jul 5, 2026

Copy link
Copy Markdown
Member

Clear touch :hover on a blank-space tap inside an Android Modal/Dialog - a separate OS window the Activity-window observer couldn't see. iOS already handles this via its key-window observer.

Demo

A blank tap inside a Modal clears a stuck :hover

Open the modal, tap the box to hover it (turns orange), then tap the blank space.

Before, the blank tap inside the Modal/Dialog window leaves :hover stuck (the Activity-window observer is blind to that window).

After, the blank tap clears it. Recorded on an Android emulator; the crosshair and yellow touch dot are the system pointer overlay showing each tap.

Before After
modal-hover-before.1.mp4
modal-hover-after.1.mp4
Example source
import React, { useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated from 'react-native-reanimated';

export default function EmptyExample() {
  const [open, setOpen] = useState(false);

  return (
    <View style={styles.container}>
      {/* A `:hover` element must be registered before the modal opens so the
          extra-window observer is installed in time. This tiny always-mounted
          view is enough. */}
      <Animated.View
        style={{
          width: 1,
          height: 1,
          backgroundColor: { default: 'transparent', ':hover': 'transparent' },
        }}
      />

      <Pressable style={styles.button} onPress={() => setOpen(true)}>
        <Text style={styles.buttonText}>Open modal</Text>
      </Pressable>

      <Modal transparent visible={open} onRequestClose={() => setOpen(false)}>
        <View style={styles.backdrop}>
          <View style={styles.card}>
            {/* Tap this box to hover it (turns orange)... */}
            <Animated.View
              onStartShouldSetResponder={() => true}
              style={{
                width: 160,
                height: 160,
                borderRadius: 24,
                backgroundColor: { default: '#3b82f6', ':hover': '#f76707' },
                transitionDuration: '150ms',
              }}
            />

            {/* ...then tap this blank space: it clears the hover */}
            <View style={styles.blankSpace} />

            <Pressable style={styles.button} onPress={() => setOpen(false)}>
              <Text style={styles.buttonText}>Close</Text>
            </Pressable>
          </View>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    gap: 20,
  },
  button: {
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 12,
    backgroundColor: '#3b82f6',
  },
  buttonText: {
    color: 'white',
    fontWeight: '600',
  },
  backdrop: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.4)',
  },
  card: {
    padding: 28,
    gap: 20,
    borderRadius: 22,
    alignItems: 'center',
    backgroundColor: '#0b1020',
  },
  blankSpace: {
    height: 120,
    width: '80%',
  },
});

@MatiPl01 MatiPl01 self-assigned this Jul 5, 2026
@MatiPl01 MatiPl01 force-pushed the @matipl01/css-active-slop-dismiss branch from a942db1 to efea196 Compare July 8, 2026 11:24
@MatiPl01 MatiPl01 force-pushed the @matipl01/css-hover-modal-clear branch from 3342ada to df014ea Compare July 8, 2026 11:24
@MatiPl01 MatiPl01 force-pushed the @matipl01/css-active-slop-dismiss branch from efea196 to 1d101f7 Compare July 8, 2026 13:35
@MatiPl01 MatiPl01 force-pushed the @matipl01/css-hover-modal-clear branch from df014ea to a5819d9 Compare July 8, 2026 13:35
@MatiPl01 MatiPl01 force-pushed the @matipl01/css-active-slop-dismiss branch 2 times, most recently from 558e6c8 to 23a0139 Compare July 8, 2026 21:51
Base automatically changed from @matipl01/css-active-slop-dismiss to main July 8, 2026 21:59
Observe each Modal/Dialog window's Window.Callback (fed by RN 0.86's public ExtraWindowEventListener) like the Activity window, so a blank-space press inside a modal clears :hover, matching iOS and mobile web. The hover coordinator becomes multi-window: observers are held weakly, the hit-test is rooted per-window, and a dismissed window clears only its own :hover. The listener is referenced reflectively (absent before RN 0.86, gated on IS_REACT_NATIVE_86_OR_NEWER) via a Proxy that handles equals/hashCode/toString so registering it into ReactContext's listener set cannot NPE; on older RN the per-view fallback stays.
@MatiPl01 MatiPl01 force-pushed the @matipl01/css-hover-modal-clear branch from a5819d9 to e222b3e Compare July 8, 2026 22:10
@MatiPl01 MatiPl01 marked this pull request as ready for review July 9, 2026 07:51
@MatiPl01 MatiPl01 requested a review from tjzel July 9, 2026 07:51
@MatiPl01 MatiPl01 added this pull request to the merge queue Jul 9, 2026
Merged via the queue into main with commit 2538747 Jul 9, 2026
9 checks passed
@MatiPl01 MatiPl01 deleted the @matipl01/css-hover-modal-clear branch July 9, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants