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-viewerUsage
<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>A gallery
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
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | — | |
index | number | — | Controlled page — which trigger's image is showing, in page order. |
defaultIndex | number | 0 | Initial page when uncontrolled. |
onIndexChange | (index: number) => void | — | |
blur | boolean | true | Blur the page behind the picture. Needs the optional expo-blur, and dims instead without it. Reduce Transparency draws an opaque backdrop. |
maxScale | number | 4 | How far a pinch can zoom in, as a multiple of the fitted size. |
doubleTapScale | number | 2.5 | Where a double tap zooms to, as a multiple of the fitted size. |
haptics | boolean | true | Tick as a drag to dismiss begins. Needs the optional expo-haptics. |
showClose | boolean | true | Draw the close button. Tapping outside the picture and dragging it away still close it. |
closeLabel | string | 'Close' | Read out for the close button. |
inset | number | 16 | Space 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. |
cornerRadius | number | 16 | Corner 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Classes on the pressable. Without children the image fills it, so give it a size — h-48 w-full, aspect-square flex-1. |
source | ImageSourcePropType | — | The picture. Shown in the page and, until fullSource loads, in the viewer. |
fullSource | ImageSourcePropType | — | A 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. |
alt | string | — | Described for a screen reader, on the trigger and in the viewer. |
caption | ReactNode | — | Shown under the picture while it is open. A string is set as text. |
index | number | — | Page order within the root. Render order when left out. |
radius | number | 0 | The 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. |
width | number | — | The 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. |
height | number | — | |
disabled | boolean | false | Nothing 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