Image Viewer

A full-screen viewer that opens out of a thumbnail — pinch, pan and double-tap zoom, swipe between images, drag to dismiss.

Open Source

Installation

Image Viewer ships with the library — install it once, then import:

import { Image Viewer } from "@delacour/react-native-ui/image-viewer";

Or copy the source into your project, to own and edit it:

bunx delacour@latest add image-viewer

Usage

app/index.tsx
import { ImageViewer } from "@/components/ui/image-viewer";
import { View } from "react-native";

export default function Example() {
  return (
    <View className="flex-1 items-center justify-center p-4">
      <ImageViewer>
        <ImageViewer.Trigger
          alt="A harbour at dawn"
          className="size-40 rounded-lg"
          id="harbour"
          source={{ uri: "https://picsum.photos/seed/harbour/800/600" }}
        />
      </ImageViewer>
    </View>
  );
}

Tap the thumbnail and the photo grows out of its own frame to fill the screen; close it and it flies back. alt is required — it is what a screen reader hears on the thumbnail and in the viewer.

Every ImageViewer.Trigger inside one ImageViewer joins its gallery, in mount order. Swipe sideways between them; the counter reads "2 / 6".

Gallery
<ImageViewer>
  <View className="flex-row flex-wrap gap-2">
    {photos.map((photo) => (
      <ImageViewer.Trigger
        alt={photo.alt}
        className="aspect-square w-[31%] rounded-md"
        id={photo.id}
        key={photo.id}
        source={photo.thumb}
      />
    ))}
  </View>
</ImageViewer>

Zoom and gestures

Zoom
GestureWhat it does
PinchZooms about the point under your fingers, up to maxScale (4×). Past either end it gives, then settles
Double-tapZooms to doubleTapScale (2.5×) about the tapped point; again to zoom out
Drag, zoomedPans the image, resisting at its edges
Swipe sidewaysThe next or previous image, one per swipe
Drag up or downDismisses past 15% of the screen or with a flick; a shorter drag springs back
Single tapHides or shows the close button, counter and caption

While zoomed, a sideways drag pans the image rather than paging: zoom out to move on. The chrome hides itself while you are zoomed in.

Drag to dismiss

isDismissible={false} turns the drag-to-dismiss off. The close button and Android's back button still close the viewer.

Full-resolution sources

Give a fullSource and the viewer loads it once open. The thumbnail draws immediately underneath — it is already decoded — and the sharp image fades in over it. If the full source fails, the thumbnail stays.

<ImageViewer.Trigger
  alt="Wind-ribbed sand dunes"
  caption="Dunes, late afternoon"
  fullSource={{ uri: "https://example.com/dunes-1600.jpg" }}
  height={1600}
  id="dunes"
  source={{ uri: "https://example.com/dunes-200.jpg" }}
  width={1600}
/>

width and height are the image's intrinsic size. They are optional, but with them the opening frame is right from the first frame instead of correcting once a remote image reports its own size.

Data mode for lists

A virtualised list unmounts rows as they scroll away, so a gallery built from mounted Triggers would shrink. Pass the whole gallery as images, and give each Trigger an index into it.

<ImageViewer images={photos}>
  <FlatList
    data={photos}
    keyExtractor={(photo) => photo.id}
    numColumns={3}
    renderItem={({ index }) => <ImageViewer.Trigger className="aspect-square flex-1" index={index} />}
  />
</ImageViewer>

If the Trigger for the image showing has scrolled out of view by the time the viewer closes, it fades out instead of flying to a frame nobody can see.

Controlled

open with onOpenChange, and index with onIndexChange, each work controlled or not. Open straight at an image from anywhere:

const [isOpen, setOpen] = useState(false);
const [index, setIndex] = useState(0);

<ImageViewer index={index} onIndexChange={setIndex} onOpenChange={setOpen} open={isOpen}>
  {triggers}
  <Button onPress={() => { setIndex(2); setOpen(true); }}>
    <Button.Label>Open the third</Button.Label>
  </Button>
</ImageViewer>

Inside the viewer's tree, useImageViewer() returns the same state and the actions — open(index?), close(), scrollTo(index).

Custom images with renderImage

The viewer draws React Native's Image. renderImage swaps in anything else — expo-image for its cache, say — without the library taking it as a dependency. It is handed the source, the resize mode, the style and two callbacks.

import { Image as ExpoImage } from "expo-image";

<ImageViewer
  renderImage={(image) => (
    <ExpoImage
      contentFit={image.resizeMode}
      onError={image.onError}
      onLoad={(event) => image.onLoad(event.source)}
      source={image.source}
      style={image.style}
    />
  )}
>
  {triggers}
</ImageViewer>

Custom thumbnails

Children replace the drawn thumbnail. The Trigger still opens on its own image, and still flies from its own frame.

Custom thumbnail
Custom thumbnail
<ImageViewer.Trigger alt="A cabin under snow" className="rounded-xl border border-border bg-card" id="cabin" source={source}>
  <View className="flex-row items-center gap-3 p-2">
    <Image className="h-16 w-20 rounded-md" source={source} />
    <Text weight="semibold">Hut, second night</Text>
  </View>
</ImageViewer.Trigger>

Accessibility

  • A Trigger is an image button labelled by its alt, hinted "Opens full screen image viewer".
  • In the viewer, the image is one adjustable element: swipe up or down to change image, and it reads "2 of 5". Its actions menu has Zoom in, Zoom out and Reset zoom, so detail is reachable without a pinch.
  • Focus moves into the viewer when it opens and back to the thumbnail when it closes.
  • The close button is always there and labelled "Close image viewer"; no gesture is the only way out.
  • Under reduce motion there is no flight: the viewer cross-fades in and out, and zoom settles without a spring. Pinch and pan still follow your fingers.

Theming

The stage and its chrome paint with two tokens, media and media-foreground. Both are derived — media is foreground in light and background in dark, so the stage is dark in both themes, and media-foreground is the reverse. A pasted palette carries them along. See Tokens.

API Reference

Anatomy

PartWhat it is
ImageViewerOwns the open state, the index and the gallery; renders the viewer while open
ImageViewer.TriggerA thumbnail that opens the viewer at its image, and the frame the image flies from
ImageViewer.CloseA close button. The chrome already draws one

ImageViewer

Prop

Type

ImageViewer.Trigger

Takes every Pressable prop but onPress, plus either an image (composed mode) or an index (data mode).

Prop

Type

ImageViewerImage

Prop

Type

ImageViewerRenderImageProps

Prop

Type

ImageViewer.Close

Takes every Button prop but onPress. children replaces the cross; accessibilityLabel defaults to "Close image viewer".

useImageViewer

Prop

Type

On this page