Skip to content
Native Animation

React Native Overlay Views: Anchored Popover

By Native Animation · Updated

React Native overlay views place temporary content above part of your app: an anchored popover, an outside-touch backdrop, or a sheet. This guide builds an anchored action popover with measured placement, outside-tap dismissal, Android back handling, focus entry and restoration, and a reduced-motion path.

This is about layers inside your application. Drawing windows over other apps is an operating-system feature with separate permissions and platform restrictions; this guide and the linked components do not implement it. Video compositing overlays are a different problem too.

Choose the presentation boundary first

SurfaceUse it forWhat the host must decide
TooltipOptional help beside a controlAnchor tracking and a touch-accessible help trigger
Anchored popoverA small set of commands beside their triggerPlacement, dismissal, input blocking, and focus
BackdropDimming and optionally intercepting outside touchesWhether background controls remain interactive
Bottom sheetMore detail, snap points, or a longer taskGesture/scroll ownership and whether the sheet is modal
React Native ModalA presentation boundary above the enclosing native viewFocus, dismissal, and platform presentation behavior

Overlay describes visual layering; modal describes whether the rest of the interface is unavailable. This tutorial's popover is a modal interaction within one screen: its backdrop blocks the background and keyboard focus stays inside until dismissal. A nonmodal popover should instead leave background controls available and allow Tab to leave it.

Start with React Native Modal when the native presentation boundary is what you need. It is built in, so there is no Expo package to install for Modal. A transparent Modal can contain a custom backdrop and card, but does not position the card at a trigger for you. Android dismissal belongs in onRequestClose there. A local sibling layer is useful when the screen owns the entire interaction and you want host-relative placement.

Setup and host contract

The file targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0:

npx expo install react-native-reanimated react-native-worklets

Use bunx expo install in Bun projects; follow the SDK 58 Reanimated setup. Save this complete example as AnchoredPopover.tsx outside your Expo Router route directory and render PopoverExample in a screen. The screen supplies a safe-area-bounded region at least 280 points wide and 320 points high; a smaller host displays an explanation instead of opening unusable controls.

The host must contain all background content that this interaction blocks. App-wide navigation or controls outside it need the same focus and accessibility boundary, or a real Modal. Keep this host outside clipped cards and scrolling content. This example dismisses on host layout changes and background scroll rather than following a moving trigger. Pressable handles discrete actions, so Gesture Handler is not required.

Build the anchored action popover

import type { ComponentRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
  AccessibilityInfo, BackHandler, findNodeHandle, Keyboard, Platform,
  Pressable, ScrollView, StyleSheet, Text, View,
} from 'react-native';
import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';

type Rect = { x: number; y: number; width: number; height: number };
type Size = { width: number; height: number };

function focusControl(control: ComponentRef<typeof View> | null) {
  if (!control) return;
  if (Platform.OS === 'web') control.focus();
  else {
    const tag = findNodeHandle(control);
    if (tag) AccessibilityInfo.setAccessibilityFocus(tag);
  }
}

function ActionLayer({ anchor, size, onClose, onArchive }: {
  anchor: Rect;
  size: Size;
  onClose: () => void;
  onArchive: () => void;
}) {
  const first = useRef<ComponentRef<typeof View>>(null);
  const cancel = useRef<ComponentRef<typeof View>>(null);
  const [contentHeight, setContentHeight] = useState(0);
  const progress = useSharedValue(0);
  const reducedMotion = useReducedMotion();
  const width = Math.min(300, size.width - 24);
  const below = Math.max(0, size.height - anchor.y - anchor.height - 20);
  const above = Math.max(0, anchor.y - 20);
  const bottom = contentHeight <= below || below >= above;
  const maxHeight = bottom ? below : above;
  const height = Math.min(contentHeight, maxHeight);
  const left = Math.max(12, Math.min(anchor.x + anchor.width / 2 - width / 2, size.width - width - 12));
  const top = bottom ? anchor.y + anchor.height + 8 : anchor.y - height - 8;
  const ready = contentHeight > 0;

  useEffect(() => {
    if (Platform.OS !== 'android') return;
    const back = BackHandler.addEventListener('hardwareBackPress', () => { onClose(); return true; });
    return () => back.remove();
  }, [onClose]);

  useEffect(() => {
    if (!ready) return;
    progress.set(withTiming(1, { duration: reducedMotion ? 0 : 180 }));
    const frame = requestAnimationFrame(() => focusControl(first.current));
    if (Platform.OS !== 'web') return () => cancelAnimationFrame(frame);
    const keydown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') { event.preventDefault(); onClose(); }
      else if (event.key === 'Tab') {
        event.preventDefault();
        // There are exactly two enabled buttons, so either Tab direction alternates them.
        focusControl(document.activeElement === first.current as unknown ? cancel.current : first.current);
      }
    };
    document.addEventListener('keydown', keydown);
    return () => {
      cancelAnimationFrame(frame);
      document.removeEventListener('keydown', keydown);
    };
  }, [onClose, progress, ready, reducedMotion]);

  const motion = useAnimatedStyle(() => ({
    opacity: progress.get(),
    transform: [{ translateY: reducedMotion ? 0 : (bottom ? -6 : 6) * (1 - progress.get()) }],
  }));

  return (
    <View style={StyleSheet.absoluteFill}>
      <Pressable accessible={false} focusable={false}
        {...(Platform.OS === 'web' ? { tabIndex: -1 as const } : {})}
        onPress={onClose} style={[StyleSheet.absoluteFill, styles.backdrop]} />
      <Animated.View role="dialog" accessibilityLabel="Project actions"
        accessibilityViewIsModal onAccessibilityEscape={onClose}
        {...(Platform.OS === 'web' ? { 'aria-modal': true as const } : {})}
        pointerEvents={ready ? 'auto' : 'none'}
        style={[styles.card, { left, top, width, maxHeight }, motion]}>
        <ScrollView keyboardShouldPersistTaps="handled"
          onContentSizeChange={(_width, nextHeight) => setContentHeight(nextHeight)}>
          <Text accessibilityRole="header" style={styles.title}>Project actions</Text>
          <Pressable ref={first} accessibilityRole="button" accessibilityLabel="Archive project"
            style={styles.button} onPress={() => { onClose(); onArchive(); }}>
            <Text style={styles.label}>Archive project</Text>
          </Pressable>
          <Pressable ref={cancel} accessibilityRole="button" accessibilityLabel="Close project actions"
            style={styles.button} onPress={onClose}><Text style={styles.label}>Cancel</Text></Pressable>
        </ScrollView>
      </Animated.View>
    </View>
  );
}

export function PopoverExample() {
  const host = useRef<ComponentRef<typeof View>>(null);
  const trigger = useRef<ComponentRef<typeof View>>(null);
  const request = useRef(0);
  const restoreFrame = useRef<number | null>(null);
  const [size, setSize] = useState<Size>({ width: 0, height: 0 });
  const [anchor, setAnchor] = useState<Rect | null>(null);
  const [status, setStatus] = useState('Project is active.');

  const close = useCallback(() => {
    request.current += 1; // Invalidate any pending measurement.
    setAnchor(null);
  }, []);

  useEffect(() => {
    if (!anchor) return;
    // Cleanup runs when the layer closes, after the background is accessible again.
    return () => {
      restoreFrame.current = requestAnimationFrame(() => focusControl(trigger.current));
    };
  }, [anchor]);
  useEffect(() => () => {
    request.current += 1;
    if (restoreFrame.current !== null) cancelAnimationFrame(restoreFrame.current);
  }, []);

  function open() {
    Keyboard.dismiss();
    const token = ++request.current;
    if (!host.current || !trigger.current || size.width < 280 || size.height < 320) return;
    trigger.current.measureLayout(host.current, (x, y, width, height) => {
      if (request.current !== token) return;
      if (x < 0 || y < 0 || x + width > size.width || y + height > size.height) return;
      setAnchor({ x, y, width, height });
    }, () => { if (request.current === token) close(); });
  }

  return (
    <View ref={host} collapsable={false} style={styles.host}
      onLayout={({ nativeEvent: { layout } }) => {
        close();
        setSize({ width: layout.width, height: layout.height });
      }}>
      <View style={styles.background}
        accessibilityElementsHidden={!!anchor}
        importantForAccessibility={anchor ? 'no-hide-descendants' : 'auto'}
        {...(Platform.OS === 'web' ? { 'aria-hidden': !!anchor, inert: !!anchor } : {})}>
        <ScrollView onScroll={close} scrollEventThrottle={16}
          contentContainerStyle={{ padding: 24, gap: 16 }}>
          <Text accessibilityRole="header" style={styles.title}>Project overview</Text>
          <Text accessibilityLiveRegion="polite">{status}</Text>
          <Pressable ref={trigger} collapsable={false} accessibilityRole="button"
            accessibilityLabel="Open project actions" accessibilityState={{ expanded: !!anchor }}
            {...(Platform.OS === 'web' ? { 'aria-expanded': !!anchor } : {})}
            style={styles.button} onPress={open}><Text style={styles.label}>Project actions</Text></Pressable>
          {(size.width < 280 || size.height < 320) && <Text>Enlarge this panel to open actions.</Text>}
        </ScrollView>
      </View>
      {anchor && <ActionLayer anchor={anchor} size={size} onClose={close}
        onArchive={() => setStatus('Project archived.')} />}
    </View>
  );
}

const styles = StyleSheet.create({
  host: { flex: 1, position: 'relative', backgroundColor: '#F8F5FC' },
  background: { flex: 1 },
  backdrop: { backgroundColor: '#17102066' },
  card: { position: 'absolute', backgroundColor: '#FFFFFF', borderRadius: 16,
    borderWidth: 1, borderColor: '#81718F', overflow: 'hidden' },
  title: { padding: 16, fontSize: 20, color: '#24163D' },
  button: { minHeight: 48, padding: 16, justifyContent: 'center',
    borderWidth: 1, borderColor: '#81718F', borderRadius: 10, margin: 4 },
  label: { color: '#24163D', fontSize: 16 },
});

Measure in one coordinate space

The trigger's measureLayout callback returns bounds relative to the non-collapsed host. Absolute card coordinates use that same host. Do not mix a row's onLayout coordinates with window coordinates: a nested row, scroll offset, or safe-area inset will otherwise displace the popover.

After measuring its content, the card chooses below if it fits, otherwise the larger side. Its width clamps to the host with twelve-point side gutters; vertical placement leaves an eight-point gap from the trigger. Content height is limited to available space and the ScrollView keeps actions reachable with enlarged text. The initial measurement pass stays transparent and ignores pointer input. A request token prevents an old callback from opening a popover after dismissal, rotation, or unmounting.

The example has no pointer triangle. If you add one, compute its tip from the trigger center after clamping the card, and keep the tip inside a corner-safe range. Do not center the pointer on a card that has moved away from the anchor.

Keyboard dismissal can resize the host; a resize closes this layer. Reopen after the layout settles. For a popover that must stay open during scrolling or keyboard changes, update the anchor using a coordinated measurement strategy and dismiss when it leaves the visible host. Avoid per-frame React state just to track a moving layer.

Outside touches, stacking, and Android back

The background, backdrop, and card occupy separate sibling branches. The backdrop fills the host and is rendered before the card. Presses inside the card cannot bubble into the backdrop and close the popover accidentally; pressing outside calls the same close function as Cancel. The backdrop is hidden from assistive technology, with a visible forty-eight-point Cancel button as the equivalent action.

Rendering the layer after the background establishes sibling order here. If your screen has other overlapping siblings, use a shared overlay host with an explicit stacking policy. A large zIndex cannot escape an ancestor's clipping or put a local View above a separate native presentation. See React Native layout properties. Raising shadows or elevation alone does not define focus or a modal boundary.

Because this implementation uses Views rather than Modal, it registers BackHandler only while the layer is mounted, consumes Android back, and removes the listener on close. Escape and the native accessibility escape gesture use the same close function. If you replace the local layer with Modal, use onRequestClose instead: BackHandler does not receive events while a native Modal is open. For nested local overlays, let one host dispatch back/Escape to the topmost layer rather than installing competing global handlers.

Focus and accessibility are part of the overlay

The trigger reports its expanded state, with an explicit aria-expanded attribute on web. On opening, web keyboard focus or native accessibility focus moves to Archive project. The card has a named dialog role and native modal accessibility semantics. Background descendants are hidden from native accessibility while the layer exists; the web branch also marks them aria-hidden and inert. Tab and Shift+Tab loop between the example's two enabled buttons; Escape closes. Closing unmounts the layer, reveals the background, and requests focus back to the trigger on the next frame.

Native accessibility focus is distinct from hardware-keyboard focus. The focusControl helper uses AccessibilityInfo for screen readers and DOM focus only on web. If your app requires full native hardware-keyboard navigation, integrate it with your platform focus system rather than assuming this web listener covers native keys. A real Modal is often a better boundary when a screen cannot hide every background sibling.

If an action navigates away or removes the trigger, restore focus to the destination's heading or next logical control instead. This teaching action changes a status string and keeps the trigger mounted. Adding dynamic actions requires deriving the tab loop from the enabled controls; the two-button loop is intentionally specific to this example.

Reanimated drives entry opacity and translation through one shared value. Reduced Motion removes both. Closing is immediate, so the example needs no animation-completion callback or scheduleOnRN. The useReducedMotion hook reads the startup preference; subscribe to AccessibilityInfo's reduceMotionChanged event if you need live preference updates. For an animated exit, keep the layer, backdrop, and accessibility boundary mounted until completion, then use scheduleOnRN from react-native-worklets to commit the close.

Check all host edges, enlarged text, narrow layouts, repeated opens, outside taps, Escape, Android back, focus restoration, and background reading order in your screen. A scrollable card preserves targets rather than shrinking the buttons to fit.

What the paid components add

Anchored Action Popover is a paid component that adds rounded glass, a straight pointer aimed at the measured control, a twelve-point anchor gap, collision fallback, scrolling overflow, and coordinated dimming. Its supplied actions, title/body copy, theme, controlled or uncontrolled visibility, and open/close/toggle/reset ref methods support a reusable host-local menu. The host supplies current anchor rectangles and safe-area bounds; it does not automatically track scrolling controls.

The piece uses Expo Blur, Skia, and Reanimated, with its documented web CanvasKit setup. It provides native heading accessibility focus, opening/closing announcements, outside-touch dismissal, and accessibility escape. Integrate Android back, web keyboard containment, and trigger-focus restoration at your host boundary as needed; this tutorial's keyboard layer is not a claim about the component's built-in API.

Snap Sheet Backdrop, illustrates a different overlay relationship: a three-anchor sheet coordinates its translation with map recentering, scale, corner radius, dimming, and staged details. Its map markers remain selectable, so do not assume every dimmed backdrop blocks interaction. It exposes snapTo/next/selectLocation ref methods and labeled controls with alternatives to dragging. Follow the bottom sheet guide for snap geometry and gesture/scroll boundaries.

Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Integrating either piece still requires your app's navigation, focus policy, safe areas, and surrounding controls to work together. Browse the micro-interactions and liquid glass collections for related surfaces.

FAQ

Is an overlay the same as Modal in React Native?

No. An overlay can be an absolutely positioned sibling View inside a screen. Modal supplies a native presentation boundary. Either can display a dimmed backdrop; input blocking and accessibility still need a deliberate policy.

Why is my popover clipped even with a high zIndex?

Its ancestor may clip overflow, or the competing surface may live in a different native presentation. Move the overlay to a suitable common host or use Modal, then measure the trigger in that presentation's coordinate space.

Can these overlays draw above other apps?

No. They render inside your app. System windows over other apps involve separate permissions and native platform behavior, which this guide does not cover.

Should a backdrop always close the sheet or popover?

Only when that matches the interaction. This tutorial's backdrop dismisses the action popover. A map with a sheet may intentionally leave its markers selectable; a blocking task may require an explicit Cancel action instead.