npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

expo-media-viewer

v0.7.2

Published

Fullscreen image and video galleries for Expo apps on iOS, Android, and web. Thumbnails, transitions, zoom, swipe-to-dismiss, video playback, and authenticated media URLs.

Readme

Use it when your app has a feed, chat, profile, memory timeline, marketplace listing, or gallery where a thumbnail should open into a polished fullscreen viewer. You pass the media model once, render whatever layout you want, and the package owns the hard parts: image zoom, video playback, open/close transitions, thumbnail rendering, swipe-to-dismiss, fullscreen chrome, and request headers.

Inspired by @nandorojo/galeria, redesigned around mixed image/video collections.

Why It Feels Different

| Built for | What you get | |---|---| | Mixed media | Images and videos share one items array instead of separate image-only props | | Real app layouts | Bring your own grid, feed, carousel, or masonry UI through renderLayout | | Package-owned thumbnails | Static images, posters, muted looping video previews, and video duration badges | | Cross-platform fullscreen UX | Native on iOS/Android, with a fluid web overlay for desktop and mobile browsers | | Live video previews | Muted looping thumbnails use native players on mobile and browser video on web | | Private media | Global request headers plus per-item and per-thumbnail overrides |

Highlights

  • One source of truth - define media, thumbnails, headers, chrome, and duration in items
  • Any layout - call renderItem(index, options) wherever a tappable thumbnail should appear
  • Image and video support - videos get native playback plus a built-in play indicator by default
  • Live previews - loop-muted video thumbnails play inline, pause offscreen, and open smoothly
  • Transition matching - thumbnail size and borderRadius are reused by the native open/close animation
  • Blurhash placeholders - keep package-owned thumbnails from flashing empty while images or video posters load
  • Authenticated URLs - attach headers globally or per item for private CDNs and signed media
  • Local assets - supports URI strings, require(...), and Image.resolveAssetSource(...) style sources
  • iOS, Android, and web - native mobile viewers plus a browser viewer with keyboard, swipe, zoom, and video support
  • Fabric and Classic support - works with both React Native architectures

Installation

npx expo install expo-media-viewer expo-image expo-asset

For web builds, make sure the Expo web runtime packages are installed too:

npx expo install react-dom react-native-web

Then rebuild your dev client for iOS or Android:

npx expo prebuild --clean
npx expo run:ios   # or run:android

This package includes native Swift and Kotlin code, so iOS and Android require a development build. Web works through the browser implementation.

Web Support

The same MediaViewer API works on web. The package resolves to a browser implementation that:

  • Opens media in a portal above your app and locks page scroll while fullscreen is active
  • Animates from the measured thumbnail frame into fullscreen, then back to the thumbnail on dismiss
  • Preserves thumbnail crop, borderRadius, and video playback time through the open and close transition
  • Supports desktop keyboard controls with Escape, left arrow, and right arrow
  • Supports mobile-first gestures: drag-to-dismiss with snap-back, swipe between items, and pinch or drag zoom for images
  • Pauses muted looping video thumbnails when they are offscreen

No web-specific props are required. Install react-dom and react-native-web for Expo web builds, then use the same items, renderLayout, and thumbnail options across iOS, Android, and web.

Usage

import { MediaViewer, type MediaViewerItem } from "expo-media-viewer";
import { View } from "react-native";

const items: MediaViewerItem[] = [
  {
    type: "image",
    source: "https://example.com/photo.jpg",
    blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
    chrome: {
      title: "Beach sunset",
      subtitle: "July 2025",
      footer: "1 / 2",
    },
  },
  {
    type: "video",
    source: "https://example.com/video.mp4",
    thumbnail: {
      source: "https://example.com/video-poster.jpg",
      mode: "loop-muted",
      blurhash: "LGF5]+Yk^6#M@-5c,1J5@[or[Q6.",
    },
    duration: "0:18",
    chrome: {
      title: "Drone footage",
      subtitle: "September 2025",
      footer: "2 / 2",
    },
  },
];

export function Gallery() {
  return (
    <MediaViewer
      items={items}
      config={{
        theme: "dark",
        thumbnail: { fit: "cover", mode: "loop-muted" },
      }}
      onIndexChange={(event) => {
        console.log("Current index:", event.nativeEvent.currentIndex);
      }}
      renderLayout={({ items, renderItem }) => (
        <View style={{ flexDirection: "row", gap: 8 }}>
          {items.map((_item, index) =>
            renderItem(index, {
              frame: { width: 120, height: 120, borderRadius: 12 },
            }),
          )}
        </View>
      )}
    />
  );
}

Video items automatically get a play indicator. If duration is present, it is shown inside that indicator.

Live Video Thumbnails

Set a video thumbnail to mode: "loop-muted" when the thumbnail should be a live muted preview. The package owns the playback surface for that item:

  • iOS uses one AVPlayer session for the thumbnail and fullscreen viewer.
  • Android uses one Media3 ExoPlayer session and switches the target PlayerView.
  • Web uses browser video thumbnails, pauses them when they scroll offscreen, and opens fullscreen near the current playback time.
  • On iOS and Android, opening a live thumbnail keeps the same playback session instead of starting at 0:00.
  • On web, opening fullscreen starts near the thumbnail's current playback time.
  • Dismissing fullscreen returns to the muted thumbnail preview.

If thumbnail.source is provided, it is used as the poster/fallback while the live video prepares. If it is omitted, the thumbnail falls back to thumbnail.blurhash, blurhash, or a neutral placeholder until the first video frame is ready.

Thumbnail mode precedence, from highest to lowest:

  • renderItem(index, { thumbnail: { mode } })
  • item.thumbnail.mode
  • config.thumbnail.mode
  • config.thumbnail.videoMode
  • "static"

config.thumbnail.videoMode is kept as a compatibility alias for older callers. Prefer config.thumbnail.mode in new code. If both config fields are set, mode wins.

Request Headers

Use config.request.headers for defaults, then override them on item-level headers or thumbnail.headers.

<MediaViewer
  items={[
    {
      type: "video",
      source: "https://media.example.com/private/video.mp4",
      headers: { "X-Media-Scope": "original" },
      thumbnail: {
        source: "https://media.example.com/private/poster.jpg",
        headers: { "X-Media-Scope": "thumbnail" },
        mode: "loop-muted",
      },
      duration: "0:24",
    },
  ]}
  config={{
    request: {
      headers: { Authorization: `Bearer ${token}` },
    },
  }}
  renderLayout={({ renderItem }) =>
    renderItem(0, { frame: { width: 160, height: 160 } })
  }
/>

Header precedence:

  • Media requests use { ...config.request.headers, ...item.headers }
  • Thumbnail requests use { ...config.request.headers, ...(item.thumbnail?.headers ?? item.headers) }

Asset Sources

source and thumbnail.source accept URI strings or React Native image sources. Local assets are resolved with the package source resolver before they are passed to the native viewer.

const items: MediaViewerItem[] = [
  {
    type: "image",
    source: require("./assets/photo.jpg"),
  },
  {
    type: "video",
    source: "https://example.com/video.mp4",
    thumbnail: { source: require("./assets/video-poster.jpg") },
  },
];

If custom UI outside renderItem needs the same URI normalization, use resolveMediaViewerSource:

import { resolveMediaViewerSource } from "expo-media-viewer";

const uri = resolveMediaViewerSource(item.thumbnail?.source ?? item.source);

Blurhash Placeholders

Add blurhash for the default image thumbnail placeholder. Use thumbnail.blurhash when the thumbnail or video poster needs its own placeholder.

const items: MediaViewerItem[] = [
  {
    type: "image",
    source: "https://example.com/photo.jpg",
    blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
  },
  {
    type: "video",
    source: "https://example.com/video.mp4",
    thumbnail: {
      source: "https://example.com/poster.jpg",
      blurhash: "LGF5]+Yk^6#M@-5c,1J5@[or[Q6.",
      mode: "loop-muted",
    },
  },
];

For larger generated placeholders, pass dimensions:

thumbnail: {
  blurhash: {
    hash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
    width: 32,
    height: 32,
  },
}

API

<MediaViewer>

| Prop | Type | Default | Description | |---|---|---|---| | items | MediaViewerItem[] | required | Single source of truth for image/video data | | renderLayout | (args: MediaViewerLayoutRenderArgs) => ReactNode | required | Render your layout. Call renderItem(index, options) for each tappable media item | | config | MediaViewerConfig | - | Viewer theme, request defaults, thumbnail defaults, video indicator behavior, and native viewer options | | onIndexChange | (event: MediaViewerIndexChangedEvent) => void | - | Called when the fullscreen viewer changes pages | | onVideoError | (event: MediaViewerVideoErrorEvent) => void | - | Called when native video loading fails |

MediaViewerItem

MediaViewerItem is the union of MediaViewerImageItem | MediaViewerVideoItem. Use the union for mixed galleries, or the narrower item types for helpers that only accept one media kind.

| Prop | Type | Default | Description | |---|---|---|---| | id | string | generated | Stable key for the item | | type | "image" \| "video" | required | Media type | | source | string \| ImageSourcePropType | required | Fullscreen image or video source | | headers | Record<string, string> | - | Headers for the fullscreen media request | | blurhash | string \| { hash: string; width?: number; height?: number } | - | Blurhash placeholder used by the default image thumbnail | | thumbnail.source | string \| ImageSourcePropType | image: source, video: placeholder | Thumbnail or video poster source | | thumbnail.headers | Record<string, string> | headers | Headers for the thumbnail request | | thumbnail.mode | "static" \| "loop-muted" | "static" | Per-item thumbnail behavior | | thumbnail.blurhash | string \| { hash: string; width?: number; height?: number } | blurhash | Blurhash placeholder for the thumbnail or video poster | | chrome.title | string | - | Title shown in fullscreen viewer chrome | | chrome.subtitle | string | - | Subtitle shown in fullscreen viewer chrome | | chrome.footer | string | - | Bottom fullscreen text, often a counter or caption | | duration | string | - | Video item only. Optional text shown in the default video indicator |

MediaViewerConfig

| Prop | Type | Default | Description | |---|---|---|---| | theme | "dark" \| "light" | "dark" | Fullscreen viewer theme | | request.headers | Record<string, string> | - | Default headers for media and thumbnail requests | | thumbnail.fit | "cover" \| "contain" | "cover" | Default thumbnail content fit | | thumbnail.mode | "static" \| "loop-muted" | "static" | Default thumbnail mode for videos | | thumbnail.videoMode | "static" \| "loop-muted" | "static" | Deprecated compatibility alias for thumbnail.mode | | videoIndicator | boolean | true | Show the built-in video indicator for video items | | viewer.edgeToEdge | boolean | platform default | Android edge-to-edge viewer dialog | | viewer.hideBlurOverlay | boolean | false | iOS blur overlay behind the viewer | | viewer.hidePageIndicators | boolean | false | Hide native page indicator dots |

renderItem(index, options)

| Option | Type | Description | |---|---|---| | frame.width / frame.height | number \| \${number}%`| Thumbnail frame dimensions | |frame.aspectRatio|number| Thumbnail frame aspect ratio | |frame.flex|number| Flex value for grid and row layouts | |frame.borderRadius|number| Thumbnail frame radius, also used by native transition matching | |frame.backgroundColor|string| Thumbnail frame background | |thumbnail.fit|"cover" | "contain"| Per-item fit override | |thumbnail.mode|"static" | "loop-muted"| Per-item thumbnail mode override | |videoIndicator|boolean| Per-item built-in video indicator override | |overlay|ReactNode` | Non-interactive app overlay rendered above the package thumbnail and video indicator |

Android GPS Helper

import { readGpsFromPhoto } from "expo-media-viewer";

const coords = await readGpsFromPhoto(assetId, fileName);
if (coords) {
  console.log(coords.latitude, coords.longitude);
}

This uses MediaStore.setRequireOriginal() to bypass Android 10+ scoped storage GPS stripping. It returns null on iOS or when no GPS data is found.

Requirements

  • Expo SDK 52+
  • React Native 0.76+
  • iOS 15.1+
  • Android minSdk 24

License

MIT