react-native-quick-preview
v2.0.2
Published
Headless long-press quick preview for React Native. Peek at any content in a popover or sheet, swipe down to dismiss.
Maintainers
Readme
react-native-quick-preview
Long-press an item to peek at its content, swipe down to dismiss. The kind of preview you know from Instagram or iOS Peek & Pop, as a small headless library for React Native.
I wanted this pattern in my own apps and couldn't find a maintained library for it, so I built one. The API follows the approach popularized by Gorhom's bottom sheet: mount a provider once, then present from anywhere.
What you get:
- Headless: you render the preview content, the library handles presentation, gestures and dismissal
- Two variants: centered
popoverand bottomsheet - Present from a hook, or from a static handle that also works outside React components
- Swipe-to-dismiss, backdrop press, Android back button handling
- Screen reader announcements and accessibility labels
- TypeScript types, CJS + ESM builds, zero dependencies of its own
Why use this
The long-press peek is a common interaction, but the existing options each stop short in a specific way:
- Expo Router's
Link.Previewis the native peek-and-pop — but it's iOS-only, it previews the route a link points to (it navigates on tap), and it requires Expo Router with a native stack. - Native context-menu libraries (
react-native-ios-context-menu,zeego) render a real custom peek — but only on iOS, because the "preview floating above a menu" is an iOS-only OS affordance (UITargetedPreview) with no Android equivalent. They also ship native modules, so they don't run in Expo Go. @gorhom/bottom-sheetis the go-to sheet, but it's sheet-only (no peek), and its imperative API still needs a mounted component plus a ref — there's no static handle you can call from outside React.
This library fills the intersection none of them cover:
A long-press peek that renders any React node identically on iOS and Android, with no native module — presentable from anywhere, no router required.
Concretely, reach for it when you want:
- The same peek on both platforms. Pure JS + Reanimated renders your content the same way on iOS and Android.
- To preview arbitrary content, not a route or a menu. A product card, an image, a profile — anything, without navigating.
- No native build. Works in Expo Go and bare React Native; no config plugin, no prebuild.
- To present from anywhere.
QuickPreview.present(node)is importable and callable from a service, a store action, or any non-React code (afterPreviewProvideris mounted once at the root).
When not to use it: if you're iOS-only and want pixel-perfect native peek fidelity and system haptics, use react-native-ios-context-menu or Expo Router's Link.Preview — a Reanimated re-creation approximates the native feel, it doesn't reproduce it. And if you just need a bottom sheet, use @gorhom/bottom-sheet. This library is for the cross-platform, arbitrary-content peek specifically.
Add it to your app
How it works. Wrap each list or grid item in QuickPreviewPressable. A tap runs onPress (go straight to your full screen); a long-press opens the peek returned by renderPreview. Make that peek tappable and tapping it opens the full screen too — otherwise it just displays until dismissed (swipe down, tap outside, or Android back).
┌─ tap ─────────────────────────────► your detail screen
item ──┤
└─ long-press ─► peek ─┬─ tap peek ─► your detail screen
└─ swipe / tap outside ─► back to the listYou build one new thing per item: the preview content — usually a compact version of a card you already have. The "full screen" is just the route you already navigate to, so it isn't extra work.
1 · Install
npm install react-native-quick-previewIt has four peer dependencies. Most apps already have the first three — React Navigation and Expo Router both pull them in — so install whatever's missing:
npx expo install react-native-reanimated react-native-gesture-handler react-native-safe-area-context
npm install react-native-portalize| Peer dependency | What it's for | Native? |
| --- | --- | --- |
| react-native-reanimated | The peek animation (worklets) | yes |
| react-native-gesture-handler | Long-press & swipe gestures | yes |
| react-native-safe-area-context | Sheet insets | yes |
| react-native-portalize | Renders the peek above your app | no — pure JS |
If Reanimated and Gesture Handler are already installed, adding this library is JS-only — no native rebuild. (Reanimated needs its Babel plugin; Expo sets this up via babel-preset-expo. Versions and bare-RN notes are under Requirements.)
2 · Wrap your app once
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { PreviewProvider } from 'react-native-quick-preview'
export default function App() {
return (
// SafeAreaProvider is recommended so the peek clears the notch / home
// indicator. It's optional — without it the peek falls back to zero insets
// rather than crashing. Most apps already have one (React Navigation /
// Expo Router mount it for you).
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<PreviewProvider>
<YourApp />
</PreviewProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
)
}3 · Drop it onto a list item
// note: touchables used *inside* a preview must come from gesture-handler, not react-native
import { Pressable } from 'react-native-gesture-handler'
import { QuickPreview, QuickPreviewPressable } from 'react-native-quick-preview'
<QuickPreviewPressable
onPress={() => navigation.navigate('Product', { id: product.id })} // tap → full screen
renderPreview={() => ( // long-press → peek
<Pressable
onPress={() => {
QuickPreview.close()
navigation.navigate('Product', { id: product.id }) // tap the peek → full screen
}}
>
<ProductCard product={product} /> {/* reuse a card you already have */}
</Pressable>
)}
>
<Thumbnail source={product.image} />
</QuickPreviewPressable>That's the whole integration — three files touched, reusing your existing detail route as the destination.
Prefer to trigger it yourself? Grab the hook —
const { present } = useQuickPreview()— and callpresent(node, options)from any handler, or use the staticQuickPreview.present(node, options)from outside React (below).
Requirements
- React Native ≥ 0.64 (peer). Tested on RN 0.81.
- React ≥ 18.2.
- Peer dependencies (must be installed in your app):
react-native-reanimated(≥ 3; both Reanimated 3 and 4 are supported),react-native-gesture-handler(≥ 2),react-native-safe-area-context(≥ 4),react-native-portalize. - Reanimated's Babel plugin must be configured — this is required, not optional. React Native consumes this library's TypeScript source (the standard distribution model for Reanimated libraries), so the plugin transforms its worklets as part of your app's build. Expo configures it for you via
babel-preset-expo; bare RN apps add it manually (react-native-worklets/pluginon Reanimated 4,react-native-reanimated/pluginon Reanimated 3). On Expo SDK 54, do not also add the plugin by hand —babel-preset-expoalready includes it, and a duplicate breaks worklets. - Your app must render
<PreviewProvider>once, inside a<GestureHandlerRootView>(step 2 above). <SafeAreaProvider>(fromreact-native-safe-area-context) is recommended so the peek clears the notch and home indicator, but not required — without one the peek falls back to zero insets instead of crashing. React Navigation and Expo Router already mount it, so most apps have it.expo-hapticsis optional — only needed if you want haptics; pass it toQuickPreviewPressableviaonLongPressStart.
Presenting from outside components
QuickPreview is a static handle to the same controller. Use it in event handlers, services, or anywhere React context isn't available. Before the provider mounts it's a safe no-op (with a warning in dev builds).
import { QuickPreview } from 'react-native-quick-preview'
QuickPreview.present(<MyPreviewContent />, { variant: 'sheet' })
QuickPreview.update({ size: { maxHeight: 480 } })
QuickPreview.isOpen() // boolean
QuickPreview.close()QuickPreviewPressable
Most of the time you want the same thing: long-press opens a preview, a normal tap navigates. QuickPreviewPressable wires that up, including a press-down scale animation:
import * as Haptics from 'expo-haptics' // optional
import { QuickPreviewPressable } from 'react-native-quick-preview'
<QuickPreviewPressable
onPress={() => router.push(`/product/${product.id}`)}
renderPreview={() => <ProductPreview product={product} />}
previewOptions={{ variant: 'popover' }}
delay={350} // long-press delay in ms
onLongPressStart={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)}
>
<ProductCard product={product} />
</QuickPreviewPressable>onLongPressStart fires on the JS thread right before the preview opens. Use it for haptics or analytics. The library doesn't import expo-haptics itself, so you stay in control of the dependency (and Metro never has to resolve a package you don't use).
Tappable preview content
The preview renders inside a gesture-handler overlay. Buttons and tappable areas inside your preview content should use touchables from react-native-gesture-handler (not react-native) — React Native's own Pressable / TouchableOpacity don't reliably receive taps inside that overlay.
// ✗ tap may not fire inside the preview
import { Pressable } from 'react-native'
// ✓ works inside the preview
import { Pressable } from 'react-native-gesture-handler'
present(
<Pressable onPress={() => { QuickPreview.close(); router.push('/detail') }}>
<ProductPreview product={product} />
</Pressable>,
{ variant: 'popover' }
)Scrollable previews
If the preview content scrolls, use QuickPreviewScrollView instead of a plain ScrollView. Otherwise the scroll gesture and the swipe-to-dismiss gesture fight each other.
import { QuickPreviewScrollView } from 'react-native-quick-preview'
present(
<QuickPreviewScrollView>
<LongArticleContent />
</QuickPreviewScrollView>,
{ variant: 'sheet' }
)API reference
present(node, options?)
Available from useQuickPreview() and the static QuickPreview. Presents node as the preview content. Calling it while a preview is open replaces the content.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| variant | 'popover' \| 'sheet' | 'popover' | Centered card or bottom sheet |
| size | 'auto' \| number \| { maxHeight?, maxWidth? } | 'auto' | Constrains the preview container |
| dismissOnBackdropPress | boolean | true | Close when the backdrop is tapped |
| dismissOnPanDown | boolean | true | Close on swipe-down gesture |
| accessibilityLabel | string | | Label announced for the preview container |
| onOpenStart / onOpenEnd | () => void | | Open animation lifecycle |
| onCloseStart / onCloseEnd | () => void | | Close animation lifecycle |
Controller
Both useQuickPreview() and the static QuickPreview expose:
| Method | Description |
| --- | --- |
| present(node, options?) | Open a preview with the given content |
| close() | Dismiss the current preview |
| update(options) | Merge new options into the open preview |
| isOpen() | Whether a preview is currently open |
Exports
| Export | Purpose |
| --- | --- |
| PreviewProvider | Hosts the preview layer; mount once at the root |
| useQuickPreview | Controller from React context (throws outside the provider) |
| QuickPreview | Static controller handle (safe no-op before mount) |
| QuickPreviewPressable | Long-press-to-preview wrapper with tap-through |
| QuickPreviewScrollView | Gesture-aware ScrollView for preview content |
| QuickPreviewComponent | Low-level headless component, for fully custom hosts |
| QuickPreviewOptions, QuickPreviewVariant, QuickPreviewSize, QuickPreviewController | Public TypeScript types |
Accessibility
Open and close are announced to screen readers via AccessibilityInfo.announceForAccessibility, the Android hardware back button closes an open preview, the backdrop is an accessible close target, and accessibilityLabel on the container is forwarded to assistive tech.
Example app
The repo contains an Expo example app with e-commerce, article and travel preview patterns:
git clone https://github.com/Hashtagsmile/react-native-quick-preview.git
cd react-native-quick-preview
npm install
npm run build
npm run exampleThe example app is on Expo SDK 54 (React Native 0.81, Reanimated 4) and runs in Expo Go on iOS and Android. See its recording guide if you want to reproduce the demo GIFs.
Compatibility
| | Status | | --- | --- | | Platforms | iOS and Android (identical rendering — it's pure JS/Reanimated) | | Expo | Works in Expo Go and dev clients. No config plugin, no prebuild, no native module. | | Bare React Native | Supported (configure the Reanimated Babel plugin yourself). | | New Architecture | Supported. Verified on RN 0.81 + Reanimated 4 (New-Arch-only). | | Old Architecture | Supported via Reanimated 3. | | Web (react-native-web) | Not a target. It may partially work, but gestures/portals aren't tested there. |
Limitations
Be clear-eyed about what this is and isn't:
- It's a re-creation, not the native OS peek. On iOS,
react-native-ios-context-menuand Expo Router'sLink.Previewuse the systemUIContextMenuInteraction(system blur, morph, tuned haptics). This library approximates that feel with Reanimated — it does not reproduce it pixel-for-pixel. If you're iOS-only and want native fidelity, use one of those instead (see Why use this). - No built-in action menu. This presents a preview, not a context menu with actions. Render your own buttons inside the preview if you need them.
- Haptics aren't automatic. Native context menus fire system haptics for free; here you wire them via
onLongPressStart(one line — seeQuickPreviewPressable). - One preview at a time. Calling
present()while a preview is open replaces the content; previews don't stack. - Light theme by default. The popover/sheet containers use a white background. Style your own content freely; a dark container background isn't a built-in option yet.
- Screen-reader announcements are English ("Quick preview opened/closed"). Labels you pass are used verbatim, but the built-in announcement strings aren't localized yet.
- Requires the peer setup. No
GestureHandlerRootView+PreviewProvider, or a missing Reanimated Babel plugin, and it won't work — this is not a drop-in with zero configuration.
Versioning
This package follows semver. Release notes live in the CHANGELOG.
Heads up if you're coming from 1.x: version 2.0.0 replaced the old <QuickPreview visible onClose> component with the provider + controller architecture described above. v1 code will not compile against v2. The changelog has migration notes.
License
MIT © Oliver Lindblad
