ImageViewer

An image that opens out of the page over a blur, to zoom into and swipe through.

Pressing a trigger moves its picture from where it sits in the page to the middle of the screen, shown whole, and blurs everything behind it. Once it is open, pinch or double-tap to zoom, swipe sideways to the other images under the same ImageViewer, and drag the picture up or down — or tap outside it — to send it back to its place.

The flight starts from the trigger's rectangle and corner radius, so pass radius to match the rounded-* class the trigger has. A class cannot be read back, and without it the picture starts square.

The viewer opens in a portal, so it needs the PanelUIProvider at the root of the app. The blur needs the optional expo-blur; without it the page dims instead, and anyone with Reduce Transparency switched on gets an opaque backdrop.

For anything that is not a picture — a form, a confirmation — use Dialog. To hold content up while offering actions on it, use ContextMenu with a preview.

Installation

ImageViewer ships with the library — no separate install.

import { ImageViewer, Button, Post, Text } from 'panelui-native';

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

npx panelui-cli@latest add image-viewer

Usage

<ImageViewer>
  <ImageViewer.Trigger
    source={{ uri: photo }}
    alt="A harbour at dusk"
    radius={16}
    className="h-56 w-full rounded-2xl"
  />
</ImageViewer>

Composition

<ImageViewer>
  <ImageViewer.Trigger />
  <ImageViewer.Trigger />
</ImageViewer>

Every ImageViewer.Trigger under one root is a page, in render order. Pass index when the render order is not the page order — a masonry grid laid out in columns, for instance.

While its picture is out on the screen a trigger is drawn transparent rather than removed, so the page does not reflow under the blur and the picture has somewhere to fly back to. Swiping to another page shows the first thumbnail again and hides the new one.

A trigger without a known image size uses its own box's proportions until the image loads. For a remote image in a box of a different shape, pass width and height so the first flight lands on the right rectangle.

Examples

One image

The trigger draws the image cropped to fill its box, so it needs a size. radius matches rounded-2xl, which is 16 points.

<ImageViewer>
  <ImageViewer.Trigger
    source={{ uri: photo }}
    alt="A harbour at dusk"
    radius={16}
    className="h-56 w-full rounded-2xl"
  />
</ImageViewer>

Several triggers under one root. Opening any of them opens at its page, and swiping moves through the rest. onIndexChange reports the page, and index makes it controlled.

<ImageViewer onIndexChange={setPage}>
  <View className="flex-row flex-wrap gap-1">
    {photos.map((photo) => (
      <ImageViewer.Trigger
        key={photo.id}
        source={{ uri: photo.thumb }}
        alt={photo.alt}
        radius={6}
        className="aspect-square w-[32%] rounded-md"
      />
    ))}
  </View>
</ImageViewer>

Captions and a larger copy

caption is shown under the picture while it is open; a single tap on the picture hides it along with the close button. fullSource loads when the viewer opens and replaces source once it arrives, so the page can carry small thumbnails.

<ImageViewer>
  <ImageViewer.Trigger
    source={{ uri: photo.thumb }}
    fullSource={{ uri: photo.full }}
    width={3024}
    height={4032}
    alt="Cliffs above the bay"
    caption="Porthcurno, early on a Sunday"
    radius={12}
    className="h-72 w-full rounded-xl"
  />
</ImageViewer>

Your own thumbnail

Pass children to draw the picture yourself. They should show the same image cropped to fill, the way the flight begins. Here the media of a post opens the viewer; Post.Media has rounded-xl, so radius is 12.

<ImageViewer>
  <ImageViewer.Trigger source={{ uri: photo }} alt="A coin going into a piggy bank" radius={12} className="mx-4">
    <Post.Media source={{ uri: photo }} className="mx-0" />
  </ImageViewer.Trigger>
</ImageViewer>

Opening it from code

open and onOpenChange make it controlled. The picture still flies from the trigger on the current page, so the triggers have to be mounted.

const [open, setOpen] = useState(false);

<Button onPress={() => setOpen(true)}>View the photo</Button>

<ImageViewer open={open} onOpenChange={setOpen}>
  <ImageViewer.Trigger source={{ uri: photo }} alt="A harbour at dusk" className="h-40 w-40" />
</ImageViewer>

Versions

Photo journal

A week of photographs in one long scroll, one gallery across all of them, so swiping crosses from one day into the next.

<ScrollView>
  <ImageViewer>
    {days.map((day) => (
      <View key={day.title} className="gap-1.5">
        <Text weight="semibold">{day.title}</Text>
        {day.rows.map((row, i) => (
          <View key={i} className="flex-row gap-1.5">
            {row.map((photo) => (
              <ImageViewer.Trigger
                key={photo.id}
                source={{ uri: photo.uri }}
                alt={photo.alt}
                caption={photo.caption}
                radius={8}
                className="aspect-square flex-1 rounded-lg"
              />
            ))}
          </View>
        ))}
      </View>
    ))}
  </ImageViewer>
</ScrollView>

Tall and wide

A portrait, a landscape and a panorama, each fitted whole to the screen from a thumbnail of a different shape.

<ImageViewer>
  <ImageViewer.Trigger source={portrait} alt="…" radius={12} className="h-64 flex-1 rounded-xl" />
  <ImageViewer.Trigger source={landscape} alt="…" radius={12} className="h-64 flex-1 rounded-xl" />
  <ImageViewer.Trigger source={panorama} alt="…" radius={12} className="h-32 w-full rounded-xl" />
</ImageViewer>

API Reference

ImageViewer

PropTypeDefaultDescription
openbooleanControlled open state.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void
indexnumberControlled page — which trigger's image is showing, in page order.
defaultIndexnumber0Initial page when uncontrolled.
onIndexChange(index: number) => void
blurbooleantrueBlur the page behind the picture. Needs the optional expo-blur, and dims instead without it. Reduce Transparency draws an opaque backdrop.
maxScalenumber4How far a pinch can zoom in, as a multiple of the fitted size.
doubleTapScalenumber2.5Where a double tap zooms to, as a multiple of the fitted size.
hapticsbooleantrueTick as a drag to dismiss begins. Needs the optional expo-haptics.
showClosebooleantrueDraw the close button. Tapping outside the picture and dragging it away still close it.
closeLabelstring'Close'Read out for the close button.
insetnumber16Space kept between the open picture and the edges of the screen, in points. Measured from the safe area at the top and bottom, and the larger of the two is used for both, so the picture stays centred. 0 fills the screen.
cornerRadiusnumber16Corner radius of the open picture, in points. The corners stay this size on screen while the picture is zoomed or dragged. 0 squares them.

ImageViewer.Trigger

PropTypeDefaultDescription
classNamestringClasses on the pressable. Without children the image fills it, so give it a size — h-48 w-full, aspect-square flex-1.
sourceImageSourcePropTypeThe picture. Shown in the page and, until fullSource loads, in the viewer.
fullSourceImageSourcePropTypeA larger copy to show once the viewer is open. It loads when the viewer opens and replaces source when it arrives, so a feed can carry thumbnails.
altstringDescribed for a screen reader, on the trigger and in the viewer.
captionReactNodeShown under the picture while it is open. A string is set as text.
indexnumberPage order within the root. Render order when left out.
radiusnumber0The corner radius the picture has in the page, in points, so the flight starts from the same shape. Match it to the rounded-* class you gave the trigger — a class cannot be read back.
widthnumberThe image's own width and height, when known. Saves looking it up, and is the only way the viewer knows the proportions before a remote image loads.
heightnumber
disabledbooleanfalseNothing opens, and the press is not reported.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Zooming and paging do not mix: a zoomed picture pans inside its own edges, and the page only turns once it is back at its fitted size. Double-tap to get there quickly.

The open picture keeps inset points clear of the screen edges — measured from the safe area at the top and bottom — and is rounded to cornerRadius, so the blur shows on every side of it. Pass inset={0} and cornerRadius={0} for a picture that fills the screen edge to edge. Zooming in lets it grow past the margin either way.

The Android back button and the accessibility escape gesture both close it. With a screen reader, the open picture is announced with its alt, and in a gallery swiping up or down turns the page.

Public exports

Values: ImageViewer

Types: ImageViewerProps, ImageViewerTriggerProps

On this page