# Galeria wraps your own image component instead of rendering one

> Galeria is an MIT licensed image viewer for React and React Native that never draws the picture itself, it hands your image component to a native viewer. iOS and Android run through Swift and Kotlin, the web build is a reduced Framer Motion experience, and the New Architecture is a hard requirement rather than a preference.

**nandorojo/galeria** — The React (Native) Image Viewer. 📷

- Repository: https://github.com/nandorojo/galeria
- Stars: 807 · Forks: 52
- Language: Swift
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/nandorojo-galeria

## Galeria draws no pixels of the picture itself

The defining choice in this project is that Galeria never renders the image. It is an image viewer that expects you to supply the image component, the README calls this bring your own image component. The listed options include `<Image />` from `react-native`, `<SolitoImage />` from `solito/image`, `<Image />` from `next/image`, `<Image />` from `expo-image` and `<img />` on web. Whatever you place inside `<Galeria.Image>` is what gets shown, and Galeria takes care of the surrounding behaviour: shared element transitions between the collapsed and expanded state, pinch to zoom, double tap to zoom, pan to close and multi image support.

That split has a practical consequence. The image element stays yours, so it keeps its own loading behaviour, caching and styling, while the viewer layer owns gestures, the modal presentation and the transition. It also means the library does not decide resolution policy. Galeria can show different images when collapsed and expanded, which the documentation presents as a way to put small thumbnails in the grid and high resolution pictures in the viewer. The `urls` array is the source of truth for ordering, and each child receives an `index` prop that ties it to a position in that array.

The API is built around a compound component pattern. `Galeria` is the container, `Galeria.Image` wraps a single child, and `Galeria.Item` is documented as an alias for `Galeria.Image` that accepts the same props. The alias exists so that mixed media galleries, where one entry may be a video, can use one child component name for every entry.

## Swift and Kotlin carry the gesture layer on device

On iOS and Android the viewer is not a JavaScript animation. The implementation uses Swift in a file named `ImageViewer.swift` and Kotlin in a directory named `imageviewer`, and the gesture handling, the shared element transition and the modal presentation live on those native sides. The repository is laid out as an Expo module, with `ios/` and `android/` directories at the top level and an `expo-module.config.json`, and the build pipeline goes through `expo-module build`, `expo-module prepare` and `expo-module prepublishOnly`.

The dependency list is short, which fits the design. `framer-motion` and `react-native-is-edge-to-edge` are the only runtime dependencies, and `expo`, `react`, `react-dom` and `react-native` are peer dependencies, with `react-dom` marked optional for web only. The native code therefore talks to the JavaScript layer through the Expo module bridge rather than through a large third party animation runtime.

The New Architecture, called Fabric in the feature list, is described as required rather than optional. In practice that narrows who can use the library: an app that has not enabled the New Architecture cannot run it. It also means the library is not usable from a plain bare React Native project without Expo tooling in place, because the module scaffolding and the build scripts both come from the Expo module toolchain.

## The web build is a separate and smaller experience

Web is supported, but the support is explicitly a simplified version of the native experience, and it is powered by Framer Motion, which is why that package appears in the runtime dependencies. The difference is not cosmetic. The native viewer opens a paged, multi item gallery with native controls, while on web it opens one photo or video at a time, and it keeps the single item viewer with its existing close and scroll behaviour.

Several native-only affordances are also scoped to iOS and Android. The `onIndexChange` callback, which reports the index of the image currently shown and fires on initial open and while the user scrolls, is documented as iOS and Android only. The `hideBlurOverlay` prop, which removes the blur layer behind the viewer, and the control that hides the page indicator dots are both marked iOS only.

Source handling differs too. On web, Galeria uses URL strings or `{ uri }` sources directly, including bundled assets that the web bundler emits in those forms. Native numeric asset IDs and native file URIs have to be resolved by the caller before they are passed to web, which is a real porting hazard for a single codebase that imports local images and reuses the same `urls` array on both platforms. Browser autoplay policies still apply to video, and the documentation tells you to use the play button when playback is blocked.

## Video entries need a poster you already have

Video support arrived as an extension of the same `urls` array rather than a separate component. You pass `{ type: 'video', source }` entries alongside photos, and a typed entry's `source` accepts a URL string, a bundled asset such as `require('./video.mp4')`, or `{ uri: string }` for remote and local file URIs. Photo entries work in the same forms as before.

Playback is deliberately conservative. Videos open paused by default, and `autoPlayVideo` has to be set for playback to start when a video page becomes active. Controls differ by platform: native AVKit controls on iOS, Media3 controls on Android, and the browser's standard video controls on web. You tap the video to reveal the controls, playback stops when the viewer is dismissed, and on iOS and Android it also pauses when you leave the page or put the app in the background.

The sharpest constraint is the poster image. Galeria reuses the loaded image from the mounted item as the video's poster and transition image, and it explicitly does not extract video thumbnails. If the item is not mounted, or its image has not loaded yet, the poster may be unavailable. In practice this means you have to provide your own thumbnail image, from your server or generated in your app, and that thumbnail becomes the transition source. Web reuses the caller's image URL as the poster in the same way.

## Wiring a first gallery and adding a video page

The package is published as `@nandorojo/galeria` and imported by name. A single image gallery looks like this, and the point of the wrapper is visible in the shape of the code: the image component is a child, not a prop.

```tsx
import { Galeria } from '@nandorojo/galeria'
import { Image } from 'react-native' // works with ANY image component!

const url = 'https://my-image.com/image.jpg'

export const SingleImage = ({ style }) => (
  <Galeria urls={[url]}>
    <Galeria.Image>
      <Image source={{ uri: url }} style={style} />
    </Galeria.Image>
  </Galeria>
)
```

You should see your own image rendered in place, and tapping it should open the native viewer over the screen. For several images, pass an array to `urls` and give each child an `index` that matches its position. Mixed photo and video galleries use the typed form, and the media list is declared separately from the thumbnail list because a video entry needs a separate poster image:

```tsx
import { Galeria, type MediaSource } from '@nandorojo/galeria'
import { Image } from 'react-native'

const media: MediaSource[] = [
  { type: 'photo', source: 'https://example.com/photo.jpg' },
  { type: 'video', source: 'https://example.com/video.mp4' },
]
const thumbnails = [
  'https://example.com/photo.jpg',
  'https://example.com/video-poster.jpg',
]
```

```tsx
export const MixedGallery = () => (
  <Galeria urls={media} autoPlayVideo={false}>
    {thumbnails.map((uri, index) => (
      <Galeria.Item index={index} key={uri}>
        <Image source={{ uri }} style={{ width: 120, height: 120 }} />
      </Galeria.Item>
    ))}
  </Galeria>
)
```

The theme is set with a single prop, and the dark variant wraps the same structure:

```tsx
import { Galeria } from '@nandorojo/galeria'

export const DarkMode = () => (
  <Galeria urls={urls} theme="dark">
    ...
  </Galeria>
)
```

## FlashList masonry cells break the shared element transition

Galeria works inside a FlashList grid, and the documentation warns that the result can look wrong on web. The cause is given explicitly: FlashList's Masonry List wraps the views with additional cells, which breaks the z-index, and the author notes that it is not a fundamental Galeria issue. The practical consequence is that on web a gallery that renders correctly on a device may show a broken transition or a mislayered viewer, and the fix is in FlashList's layout rather than in Galeria's API.

The FlashList integration uses the same pattern as everything else. The list is rendered inside `Galeria`, each rendered item is wrapped in `<Galeria.Image index={index}>`, and the list keeps its own `numColumns` and `estimatedItemSize` settings. The documentation also notes that the item component should be memoized, which matters because the shared element transition needs a stable image element to travel from the grid into the viewer.

Two other grid-adjacent details are easy to miss. When the same array holds remote URLs and locally imported assets, the `source` prop has to switch shape, since a string becomes `{ uri: value }` while a required asset stays as it is. And the `keyExtractor` in the example combines the item and its index, which suggests the library expects the position in `urls` to stay in sync with the order in which children are rendered.

## iOS only switches have no Android counterpart

Several of the visual controls are documented for one platform only, and a team shipping both platforms has to design around that. The blur overlay that sits behind the viewer on iOS can be turned off with a boolean prop on the image wrapper:

```tsx
<Galeria.Image hideBlurOverlay>
  <Image source={{ uri: url }} style={style} />
</Galeria.Image>
```

The page indicator dots that appear when viewing multiple images have a matching hide control, also marked iOS only. Reporting the current index to your own state is likewise native only, and it is the way to keep an application level caption or counter in sync with the viewer:

```tsx
<Galeria urls={urls}>
  {urls.map((url, index) => (
     <Galeria.Image 
        index={index} key={...}  
        onIndexChange={(e) => setCurrentIndex(e.nativeEvent.currentIndex)}
        >
       <Image source={typeof url === 'string' ? { uri: url } : url} style={style} />
     </Galeria.Image>
   ))}
</Galeria>
```

The callback fires when the viewer opens and when the user scrolls through the images, and the value arrives as `e.nativeEvent.currentIndex`. None of this is available on web, where the viewer opens a single item and there is no paging to report.

The `key={...}` in that snippet is literal in the documentation, so a reader has to substitute their own key. It is a small reminder that the snippets are sketches rather than drop in files.

## MIT licence, recent releases and a last push on 2026-10-12

The licence is MIT, so the code can be used inside a commercial app without a copyleft obligation. This is a statement about the licence identifier only and not a legal opinion, but for most teams the practical effect is that embedding the viewer in a product does not require publishing the surrounding application source.

Activity is recent. The last push was on 2026-10-12, and the release history shows v3.1.0 on 2026-10-12, v3.0.3 on 2026-05-28 and v3.0.2 on 2026-05-02. One wrinkle is worth checking in your own copy of the repository: the `package.json` in the project still carries the version `2.0.0-alpha.0`, while the published tags are in the v3 line. If your tooling reads the version from the manifest rather than from the registry, that gap is a hint that the manifest is not kept in sync with the release tags.

The package declares `expo` as a peer dependency along with `react`, `react-dom` and `react-native`, and `react-dom` is optional so that native only applications do not have to install it. The `packageManager` field points at `yarn@4.1.0` and the repository ships a `.yarn/` directory with a `yarn.lock`, so the project expects a modern Yarn workspace. Scripts such as `open:ios` and `open:android` target the `example/` app, which is where the sample application lives.

## Conclusion

Galeria fits teams already on Expo and the New Architecture that need a gallery with shared element transitions and native gestures without giving up control of image rendering. It does not fit projects that need one consistent viewer on web and native, or that need the library to extract video thumbnails for them. Before adopting it, check that your bundler resolves the package on the web target, that every video poster you want is already a loaded image, and that the iOS only switches you rely on have no Android counterpart.

## FAQ

### Does Galeria render the image itself, or do I bring my own image component?

You bring your own. Galeria expects an image component as a child of `Galeria.Image`, and the documented options include `react-native` `Image`, `solito/image`, `next/image`, `expo-image` and a plain `<img />` on web. The library handles gestures, transitions and the modal, while your component keeps control of rendering.

### How do I add a video to a Galeria gallery?

Pass a `{ type: 'video', source }` entry in the `urls` array alongside photos, where `source` can be a URL string, a bundled asset such as `require('./video.mp4')` or `{ uri: string }`. Videos open paused unless you set `autoPlayVideo`, and Galeria does not extract thumbnails, so you need to provide a poster image yourself.

### Is the web support the same as the native viewer?

No. Web support is a simplified version powered by Framer Motion, and it opens one photo or video at a time instead of a paged gallery. Native numeric asset IDs and native file URIs have to be resolved by you before they are passed to web, and the index callback does not exist there.

### How do I know which image the user is currently viewing?

On iOS and Android you pass `onIndexChange` to `Galeria.Image` and read `e.nativeEvent.currentIndex`. It fires on initial open and as the user scrolls, and it is not available on web.

### Can I hide the blur overlay behind the viewer?

Yes, on iOS. Passing `hideBlurOverlay` to `Galeria.Image` removes the blur layer that appears behind the viewer. The prop is documented for iOS only, so Android builds will not respond to it.

## Sources

- [Issues](https://github.com/nandorojo/galeria/issues)
- [License: MIT](https://github.com/nandorojo/galeria/blob/master/LICENSE)
- [nandorojo/galeria on GitHub](https://github.com/nandorojo/galeria)
- [README](https://github.com/nandorojo/galeria/blob/master/README.md)
- [Releases](https://github.com/nandorojo/galeria/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/nandorojo-galeria
