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-appstore-shots

v0.4.0

Published

Generate App Store screenshots and store graphics from an Expo app's own code — no Mac, no simulator, no mock-ups.

Readme

expo-appstore-shots

App Store screenshots rendered from your Expo app's own code. No Mac, no simulator, no Figma mock-ups, no hand-editing a PNG that drifts from the product three commits later.

It runs your real screens in a headless browser — react-native aliased to react-native-web, the native modules stubbed, your backend replaced by seeded fixtures — photographs them, and wraps each shot in a store frame with your caption on it.

npm install --save-dev expo-appstore-shots
npx playwright install chromium     # once
npx expo-appstore-shots init        # writes shots.config.mjs + shots/fixtures.mjs
npx expo-appstore-shots             # → appstore/6.9/01-home.png, …

Output is the exact pixel size App Store Connect demands (1290×2796 and friends), PNG, no alpha channel — ready to upload.

It also draws the listing art that is not a screenshot, from the same brand:

npx expo-appstore-shots graphics    # → Play icon 512², feature graphic 1024×500,
                                    #   App Store marketing icon 1024²

Store graphics

The stores want more than screens. Play will not publish without a 512×512 icon and a 1024×500 feature graphic; App Store Connect wants a 1024×1024 marketing icon. These are the assets people open a drawing program for — and the assets that end up in a slightly different green from the app, in a typeface the app does not use, because they were drawn somewhere your tokens do not reach.

graphics makes them from what you already have. The icon is your assets/icon.png. The ground, the dots and the typeface are the ones your screenshot frames use. Nothing is redrawn, so nothing can drift — and the icon, the feature graphic and the screenshots sitting side by side on one store page are finally the same brand.

graphics: {
  targets: ['play-icon', 'play-feature', 'ios-marketing'],
  wordmark: 'Perron',
  tagline: 'Chat with the platform you are standing on.',
  note: 'Anonymous. Only while you are there.',
  accent: '#1C6B4F',
  iconBackground: '#FDFBF8',
  // icon: 'assets/icon.png'       — found automatically
  // mark: 'assets/logo-mark.png'  — found automatically; see below
  // promoVideo: true              — if your listing has one; see below
}

An icon tile and a bare mark are not the same picture. icon is the finished square tile — art plus its own ground — and it is what both stores want, full-bleed, corners left alone (they mask it themselves; a pre-rounded icon gets rounded twice). The feature graphic wants the mark alone, on the feature graphic's ground. Give it the tile instead and you get a hard-edged square of a slightly-wrong white floating in the middle of your brand surface. If your app has no bare mark the tile is used and rounded, so it at least reads as an app icon on purpose — and the run says so.

Two things no store will reject, which is worse — you find out from the listing:

  • Play crops the feature graphic to other aspect ratios in placements you do not choose. Anything near an edge gets cut. Every run measures the laid-out lockup and warns if it reaches within 6% of one — which is how a long app name gets caught, because a wordmark cannot wrap and so it is the thing that runs off.
  • If your listing has a promo video, Play draws a play button over the centre of the feature graphic. Centring your logo is the natural thing to do and it is exactly wrong. Set promoVideo: true and the composition stacks instead of centring, so the lockup clears the button — and it is checked, not assumed.

Every asset is written at the exact pixel size, opaque (Chromium always writes an alpha channel; App Store Connect refuses one and will not tell you which file), and checked against the store's byte ceiling before you upload it.

What is real, and what is not

Real: everything inside the phone. Your screens run their own code — your providers, your query client, your theme, your i18n, your auth bootstrap, your geofence check, your sockets. If a screen cannot render in this harness, it cannot render in production either.

Not real, and honestly so:

| | Why | |---|---| | The API answers | Seeded, by you, in shots/fixtures.mjs — shaped like your real endpoints. | | The tab bar | With expo-router's native tabs, iOS draws it, not your app. It is redrawn from your config. Set tabBar.enabled: false if your app draws its own. | | The status bar | react-native-web has none, so it is drawn into the safe-area strip your app leaves empty (9:41, full battery — Apple's own convention). | | The typeface | The device runs SF Pro, which Apple does not license for redistribution. Inter stands in unless you point frame.fontFile at your app's own font. | | A Skia canvas | There is no GPU canvas here, so @shopify/react-native-skia draws nothing. If a chart or a shader is the screen, point config.stubs at a component that draws it in ordinary views — otherwise that frame is honestly empty. | | A map | react-native-maps is a native view. MapView renders an empty box of the right size (so the rest of the screen keeps its real layout) rather than vanishing. | | Local data | expo-sqlite answers "no rows". An offline-first screen photographs as a fresh install unless you seed it — see Seeding what fetch cannot reach. |

Screenshots must represent your app truthfully — that is an App Review rule, not a style note. Seed fixtures with data your app could actually return, and caption each slide with what that slide actually shows.

Configuration

shots.config.mjs, in full:

export default {
  projectRoot: '.',
  rootLayout: 'src/app/_layout.tsx',   // your providers + header live here.
                                       // Optional: omit it and a minimal one is
                                       // used (see "React Navigation" below).
  setup: 'shots/setup.ts',             // optional: seed what fetch cannot reach
  outDir: 'appstore',

  screens: [
    { id: 'home',   module: 'src/app/(tabs)/index.tsx', route: '(tabs)', tab: 'home' },
    { id: 'detail', module: 'src/app/item/[id].tsx', route: 'item/[id]',
      params: { id: '42' }, back: true },
    // A tab screen whose header is declared in the tabs layout (never mounted
    // here) needs its title given: `title` implies a header, `header` forces it
    // either way. `scroll: 'end'` opens a chat on its newest message.
    { id: 'chat',   module: 'src/app/chat/[id].tsx', route: 'chat/[id]',
      params: { id: '7' }, back: true, scroll: 'end' },
  ],

  tabBar: {
    style: 'capsule',                  // 'capsule' = iOS native tabs; 'bar' = React Navigation's JS tabs
    tint: '#367CED',
    idle: '#0B0B0B',
    background: '#FFFFFF',
    items: [                           // lucide icon names
      { id: 'home', label: 'Home', icon: 'House' },
      { id: 'you',  label: 'You',  icon: 'CircleUserRound' },
    ],
  },

  runtime: {                           // the phone's state while it is photographed
    coords: { latitude: 52.3789, longitude: 4.9003, accuracy: 8 },
    locale: 'en-US',
    timezone: 'Europe/Amsterdam',
    clock: '2026-03-17T09:41:00+01:00',       // absolute times render from this
    storage: { 'app.onboarding': 'done' },    // AsyncStorage, pre-seeded
  },

  api: { fixtures: 'shots/fixtures.mjs' },
  env: { EXPO_PUBLIC_API_URL: 'http://127.0.0.1:8788' },

  devices: ['iphone-6.9', 'iphone-6.5'],      // also: ipad-13, ipad-12.9

  frame: {
    grounds: {
      light: { bg: '#F4F3F0', dot: '#D8D6CE', ink: '#181A17', muted: '#676F6D' },
      dark:  { bg: '#17513F', dot: '#23614D', ink: '#F5F3EE', muted: '#9FC2B3' },
    },
    dots: true,
    bezel: '#111517',
    statusBar: { time: '9:41', tint: '#181A17' },
  },

  slides: [                            // ← your marketing copy lives here
    { screen: 'home', ground: 'dark', file: '01-home.png',
      headline: 'The one true thing about this screen.',
      sub: 'A supporting line that describes what is actually on it.' },
  ],

  // Escape hatches. Most apps need none of these.
  stubs: { 'src/lib/keychain': 'shots/fake-keychain.ts' },  // replace a module
  redirect: { '(^|/)device-key$': 'shots/device-key.ts' },  // ↑ by import path
  redirectFile: { 'components/Chart\\.tsx$': 'shots/chart.tsx' },  // ↑ by real path
  loaders: { '.lottie': 'dataurl' },   // an asset format esbuild has no loader for
  apiPort: 8788,                       // a busy default moves; a set one does not
}

stubs and redirect are the same idea at two levels: stubs swaps a whole package (it is an alias, so it catches every importer), redirect swaps a module reached by a relative path. Whatever you put in stubs wins over the tool's own aliases — you can replace its lucide, its Skia, its react-native-maps.

redirect matches what an import says, not where it lands. One file has as many specifiers as it has importers — ./Chart, ../components/Chart, @/components/Chart — and your rule has to match the one esbuild is handed, for every importer. redirectFile matches the resolved path on disk instead, which is one string however many ways the app spells it. (A barrel is not a problem for either: export * from './Chart' is itself an import of ./Chart, so a Chart$ rule does fire through it.)

React Navigation

The tool grew up around expo-router, but a React Navigation app needs no special-casing beyond knowing which file to point at:

export default {
  // Your App.tsx: every provider above the navigator mounts and runs for real,
  // and at the navigator itself the tool renders the one screen being shot.
  rootLayout: 'App.tsx',
  screens: [
    { id: 'home', module: 'src/screens/HomeScreen.tsx', route: 'Home', title: 'Home' },
  ],
  tabBar: { style: 'bar', items: [/* … */] },   // 'bar' = React Navigation's JS tabs
}

createNativeStackNavigator() and friends hand back the tool's own Stack, so <Stack.Screen name="Home" options={{ title: 'Home' }} /> puts a real title in the header of the screen configured as route: 'Home'. useNavigation, useRoute, useFocusEffect, useIsFocused, useNavigationState and useScrollToTop are answered from the screen being photographed instead of throwing "Couldn't find a navigation object".

Omit rootLayout entirely and you get a minimal one — your screen, a header, and nothing above it. That is right for a screen with no root providers, and the run says so on every use.

Seeding what fetch cannot reach

The mock backend answers HTTP. Plenty of apps get their data another way, and those screens photograph empty — convincingly, with no error, because an empty list is what a fresh install looks like. It is the one failure this tool cannot see for you.

config.setup is a module of yours that runs in the page, in your app's own module world, before the first render (and is awaited, so async seeding lands before anything mounts):

// shots/setup.ts
import { useSession } from '../src/stores/session'
import { useTasks } from '../src/stores/tasks'

export default async function setup() {
  // An entitlement, so feature-gated screens render their unlocked path.
  useSession.setState({ user: { name: 'Sam' }, entitlements: { pro: true } })

  // A store the screens read directly — SQLite-backed, hydrated on boot, etc.
  // This is also how you switch off an `isInitializing` flag that would
  // otherwise photograph as a skeleton forever.
  useTasks.setState({ tasks: [{ id: '1', title: 'Rendered from the app' }], isInitializing: false })
}

Three things it solves, all of which look identical from the outside (an empty screen):

| | | |---|---| | A root bootstrap that never runs | The harness mounts one screen, not your app root. If your data is fetched and hydrated by a root lifecycle hook, either point rootLayout at the file that runs it, or call it here. | | A local database | expo-sqlite is stubbed and every query answers "no rows". Seed the store the screens read, or point config.stubs at your own repository/db.ts and return fixtures from one layer up — your screens then read their real code paths. | | Feature gating | Content behind a subscription check renders empty even when its data is present. Grant the entitlement here. |

Fixtures

Keys are METHOD /path, :name matches any segment, and a value can be data or a function of { params, query }:

export const routes = {
  'POST /api/session': { token: 'demo', userId: 'u_1' },
  'GET /api/items': [{ id: '1', title: 'Rendered from the app' }],
  'GET /api/items/:id': ({ params }) => ({ id: params.id, title: 'One item' }),
}

export const fallback = {}       // anything unmatched
export const prefix = '/api/'    // everything under here is the API

/** If your app opens a WebSocket, script what it receives on connect. */
export function ws(send) {
  send({ type: 'history', messages: [{ id: 'm1', body: 'Anyone else stuck here?' }] })
}

When a screen comes out wrong

Blank, with a React #527 error — your app's React and the bundled react-dom disagree. Install a matching react-dom in the app: npm i -D react-dom@19.

A permission prompt or an empty state — the app is reading state this tool does not know about. Seed it: runtime.storage, runtime.secureStore, or a fixture route.

A module the browser cannot load (a keychain wrapper, an analytics client) — replace it. stubs matches an import path exactly; redirect matches the end of one, which is how you catch a module imported relatively from several places:

stubs:    { '@/lib/device-key': 'shots/stubs/device-key.ts' },
redirect: { '(^|/)device-key$': 'shots/stubs/device-key.ts' },

Something below the fold is missing — the screen is taller than the viewport. That is also true on device; scroll position is what a screenshot captures.

Every icon in the app is gone — lucide could not be resolved. The run says so now, loudly, and the fix is almost always npm install. (It used to say nothing: the tool concluded "no lucide", swapped in an empty stub, and every glyph in the app quietly became nothing.)

No matching export … for import "X" — a stub is missing a name your app imports. Every screen bundles into one file, so this fails the whole run even if only one dev-only screen imports it. Add it yourself with stubs, and please open an issue: the stub should have had it.

port 8788 is already in use — an interrupted run is still holding it. kill $(lsof -ti :8788). If you never set apiPort yourself, the run moves to the next free port instead and only mentions it.

A screen shows an empty state you did not expect — read the end of the run. Every API call that no fixture matched is listed there; the empty state is almost always one of them.

No header on a tab screen — its header is declared in the tabs layout, which a one-screen harness never mounts, while the root layout hides the header for the whole tab group. Give the screen a title.

The screen now says its own name twice — the trap on the other side of that one. If the screen already prints its name as a heading, a title puts it in the bar as well and the frame carries the word twice, once small and once large, with nothing technically wrong anywhere. Use header: false instead: the screen titles itself. Every run measures the laid-out page and says so when this happens.

Requirements

Node 20+, and Chromium (npx playwright install chromium, ~130MB, once). Your Expo app needs its own node_modules installed — the tool bundles the app from them.

On pnpm, pnpm add -D expo-appstore-shots prints Ignored build scripts: esbuild and moves on. esbuild's binary is then not set up and the first run fails somewhere unhelpful. Once:

pnpm rebuild esbuild

or, better, in the app's package.json:

{ "pnpm": { "onlyBuiltDependencies": ["esbuild"] } }

Works with: expo-router (Stack and native tabs) and React Navigation (native-stack, stack, bottom-tabs, drawer, elements); Reanimated 3/4, Gesture Handler, @gorhom/bottom-sheet, FlashList, react-native-svg, lucide-react-native (any version), @shopify/react-native-skia, react-native-maps, expo-sqlite, react-native-fast-confetti, react-native-android-widget; expo-location / secure-store / haptics / notifications / constants / clipboard / crypto / device / localization / task-manager / status-bar / camera / image-picker / media-library / file-system (both the File API and the flat one) / web-browser / auth-session / linking; react-native-iap, AsyncStorage. Anything else, stub it yourself in three lines with config.stubs — and a native package that reaches for react-native/Libraries/… internals resolves to a no-op instead of ending the run.

"Works with" means it bundles and renders, not that it draws. A Skia canvas, a map and a home-screen widget have no browser equivalent and are listed under What is real, and what is not — read that before you trust a frame that contains one.

For AI agents

See AGENTS.md — a step-by-step procedure for setting this up in an unfamiliar repo, including how to discover the screens, what to seed, and how to check the result.

Point your agent at it and it will read the app, then come back and ask you what the set should say — which screens, in what order, what the captions argue, what the frames look like — offering choices drawn from your own code, with "you decide" as the last option. It cannot get that out of the repo, and the cheapest moment to disagree is before six frames exist. Say "you decide" and it will.

MIT.