Image viewer
Free · MITReact Native · ExpoTap a thumbnail and the photo flies out of it into a full-screen viewer, its crop opening up as it lands; pinch or double-tap to zoom at your fingers, drag to pan, swipe between photos, and drag down to send it back into its thumbnail while the background fades. On the web the wheel zooms toward the pointer.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/image-viewer.jsonThen run npx expo install --fix so react-native-gesture-handler, react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/image-viewer.
Usage
import { View } from "react-native";
import { ImageThumbnail, ImageViewer, type ViewerImage } from "@/components/goodui-native/image-viewer/image-viewer";
const photos: ViewerImage[] = [
{ source: "https://example.com/1.jpg", width: 1200, height: 800, alt: "Living room", caption: "Living room, 24 m²" },
{ source: "https://example.com/2.jpg", width: 800, height: 1200, alt: "Kitchen" },
];
export function Gallery() {
return (
<ImageViewer images={photos}>
<View style={{ flexDirection: "row", gap: 4 }}>
{photos.map((_, i) => (
<ImageThumbnail key={i} index={i} style={{ flex: 1, aspectRatio: 1 }} />
))}
</View>
</ImageViewer>
);
}When to use
- Photo galleries, listing and product photos, attachments in a chat, receipts and documents people need to read closely.
- Any thumbnail that should open the full image with zoom.
- Swiping through cards or banners inline: use carousel.
- Story-style full-screen media that advances on its own: use stories.
- A single decorative image that never needs to be larger: use an Image.
Examples
Open from your own trigger
function FloorPlanButton() {
const viewer = useImageViewer();
return <Button onPress={() => viewer.open(0)}>View floor plan</Button>;
}
<ImageViewer images={[plan]}>
<FloorPlanButton />
</ImageViewer>Know which photo is on screen
<ImageViewer images={photos} onIndexChange={setIndex} onOpenChange={(open, i) => track(open ? "open" : "close", i)}>API reference
ImageViewer
| Prop | Type | Default |
|---|---|---|
| images* Every image the viewer can swipe through, in order. | ViewerImage[] | — |
| children* Your layout, with an ImageThumbnail per image (or any trigger that calls `useImageViewer().open`). | ReactNode | — |
| onOpenChange Called when the viewer opens or closes, with the image it is on. | (open: boolean, index: number) => void | — |
| onIndexChange Called when a swipe (or arrow key) moves to another image. | (index: number) => void | — |
| maxZoom Largest pinch zoom. Default 4. | number | 4 |
| doubleTapZoom Zoom a double tap jumps to, centered on the tap. Default 2.5. | number | 2.5 |
| showCounter "2 of 5" at the top. Default `true` with more than one image. | boolean | true |
| labels Overrides for the built-in strings (viewer and thumbnails); a GooduiLocaleProvider pack sets them app-wide. | Partial<ImageViewerLabels> | — |
ImageThumbnail
| Prop | Type | Default |
|---|---|---|
| index* Which of the viewer's `images` this thumbnail shows and opens. | number | — |
| style Size and shape. Give it a width and height or an aspectRatio. | StyleProp<ViewStyle> | — |
| radius Corner radius. The image grows out of these corners and returns into them. Default 14. | number | 14 |
| resizeMode How the thumbnail crops. Default `cover`. | "none" | "center" | "contain" | "cover" | "repeat" | "stretch" | "cover" |
| accessibilityLabel Screen reader label. Defaults to the image's alt text. | string | — |
| testID Identifier for end-to-end tests. | string | — |
Motion and haptics
- Open: the frame travels from the thumbnail's box and corner radius to the image's fitted box on the smooth spring; the image covers the frame, so the thumbnail's crop opens up exactly as it lands. The backdrop fades in.
- Pinch zooms around the fingers (resisting past 1 and maxZoom, then settling back); double-tap zooms to doubleTapZoom at the tap point or back to fit.
- When zoomed the image pans 1:1, resists past its edges and keeps its momentum on release (decay, clamped to the edges).
- At fit, a sideways drag moves between images 1:1 and settles on the smooth spring with the release velocity; the first and last images resist.
- A vertical drag follows the finger, shrinks the image a little and fades the backdrop; release past 110 pt (or a fast flick) sends it back into the thumbnail of the image on screen, otherwise it springs back.
- Tap toggles the controls. A size that arrives late (no width and height given) springs the frame to the right shape.
- Reduced motion: opening and closing fade instead of flying, and page changes from the keyboard or buttons jump. Pinch, pan and drags still follow the finger because they are direct manipulation.
- Haptic: tap on the close button (IconButton); zooming, paging and dismissing are direct manipulation and stay silent
- All gestures and the open and close flight are worklets on the UI thread; React re-renders only when the image index changes or the controls toggle.
- Only the current image and its two neighbours are mounted. Image sizes are read once per image (Image.getSize) when width and height are not given.
Accessibility
- Each ImageThumbnail is a button labelled with the image's alt text and the hint 'Opens the image'.
- The viewer is modal. The image is one element labelled 'alt, 2 of 5' with actions Next image, Previous image and Close; the iOS escape gesture and Android's back button close it.
- Moving to another image announces 'Image 3 of 5'; opening announces the position too.
- A close button sits at the top with a '2 of 5' counter; on the web there are also previous and next buttons, arrow keys move between images and Escape closes.
- Captions show in a quiet card at the bottom and are plain text.
Notes for AI
- Wrap the thumbnails in ImageViewer and give each ImageThumbnail the index of its image; give the thumbnail a size through style (width plus aspectRatio, or flex).
- Pass width and height on each image when you know them so the opening flight lands on the right shape at once.
- Use the same source for the thumbnail and the viewer; the viewer does not load a separate high resolution file.
- useImageViewer().open(index) opens from any trigger inside ImageViewer; without a thumbnail the image scales in from the center.
- The viewer renders through Portal: ThemeProvider (or a PortalHost) must wrap the app.
Related
A snapping card carousel that follows the finger 1:1, shrinks and dims the neighbours, stretches its pagination pill with the drag, and turns that pill into a timer when it autoplays.
An Instagram-style story viewer that grows out of the tapped avatar: segmented bars fill at the top, tap right or left to step, hold to pause while the chrome steps aside, swipe sideways to the next person with a plain slide, and drag down to send it back into the avatar.
Message bubbles that group into runs with one tail, carry a receipt that ticks forward (clock, a check that draws itself, two checks, both brightening when read) and spring up out of the composer; plus a TypingIndicator whose dots wave and a Composer whose mic morphs into a filled send arrow as soon as there is text.