DEV Community

Hueflo
Hueflo

Posted on

Building a browser-only App Store screenshot generator: sizes, alpha channels and one ZIP

Every time I shipped an app update, the slowest part wasn't the code. It was the screenshots. Apple wants one set of pixel sizes, Google Play wants another, both reject transparency, and the moment you add a headline above your UI you're redoing that work for every device class.

So I built a screenshot generator that runs entirely in the browser: drop in raw simulator captures, add a caption and background, and export every store size as one ZIP. It's plain ES modules on a <canvas>. No framework and no build step. The user's images never leave the tab.

This post covers the parts that were less obvious than I expected: getting the size list right, making sure exports really have no alpha channel, laying out one design across very different aspect ratios, and naming files so the ZIP is easy to upload.

1. Start from the official size tables, not blog posts

Most "screenshot size cheat sheets" are out of date within a year. I copied the presets directly from the two official sources and put a "verified on" date in the code:

// lib/presets.js
export const VERIFIED_ON = '17 Oct 2026';
export const SOURCES = {
  apple: 'https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/',
  google: 'https://support.google.com/googleplay/android-developer/answer/9866151?hl=en',
};
Enter fullscreen mode Exit fullscreen mode

Here's what those pages say right now, trimmed to the sizes most apps need:

Store / display Size (portrait, px) Notes
App Store, iPhone with Dynamic Island (medium display) 1206 × 2622 or 1179 × 2556 Required for iPhone apps
App Store, iPhone with Dynamic Island (large display) 1320 × 2868, 1290 × 2796 or 1260 × 2736 Optional
App Store, iPhone with Face ID (large display) 1284 × 2778 or 1242 × 2688 Optional
App Store, iPhone Duo outer display 1398 × 2034 New device class
App Store, iPhone Duo inner display 2007 × 2853 New device class
App Store, iPad 13" display 2064 × 2752 or 2048 × 2732 Required if your app runs on iPad
App Store, iPad 11" display 1668 × 2388 (plus three others) Optional
Google Play, phone 1080 × 1920 recommended (9:16) 320–3840 px, long side ≤ 2× short side
Google Play, tablet 9:16 or 16:9, e.g. 1440 × 2560 / 2560 × 1440 Large screens: 1,080–7,680 px
Google Play, feature graphic 1024 × 500 Required to publish

Landscape is the same numbers swapped. A few rules matter as much as the sizes:

  • Apple: 1 to 10 screenshots per device size, .jpeg, .jpg or .png. Apple's spec page says: "Images can't include alpha channels or transparencies." If your UI is the same across sizes, you only need the highest required resolution, and App Store Connect scales it down for smaller devices.
  • Google Play: at least 2 screenshots to publish and up to 8 per device type. Files must be JPEG or 24-bit PNG with no alpha. To be eligible for the large-format recommendation surfaces, apps need at least 4 screenshots at 1080 px or more, in 9:16 or 16:9.
  • iPhone Duo: Apple's spec page notes, "Starting in April 2027, screenshots will be required for any app using the iOS 27.1 SDK or later." The announcement post words the scope a little differently, so read both before planning around it.

In code, each preset stores its portrait size. Landscape is derived, and fixed-size assets like the feature graphic opt out:

export function sizeFor(preset, orientation = 'portrait') {
  if (preset.fixed || orientation !== 'landscape') return { w: preset.w, h: preset.h };
  return { w: preset.h, h: preset.w };
}
Enter fullscreen mode Exit fullscreen mode

2. "No alpha" means no alpha channel, not just opaque pixels

This is the one that bites people. A screenshot can look completely opaque and still be stored as an RGBA PNG (colour type 6). Both stores ask for no alpha, so I didn't want to depend on what the browser's encoder happens to write.

I did two things.

First, an opaque base on every render. The export canvas is created with alpha: false and filled with the background colour before anything is drawn, so a transparent corner in an uploaded screenshot can't leak through:

const ctx = canvas.getContext('2d', { alpha: false, willReadFrequently: true });
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = bgHex;
ctx.fillRect(0, 0, size.w, size.h); // opaque base: no alpha can survive
await renderInto(ctx, { preset, size, orientation, slideIndex });
Enter fullscreen mode Exit fullscreen mode

Second, a tiny PNG encoder that writes 8-bit RGB (colour type 2). canvas.toBlob('image/png') leaves the pixel format up to the browser. Instead I read the pixels, composite any leftover alpha onto the background, drop the fourth byte, and write the PNG chunks myself. That's less work than it sounds, because the browser already ships zlib-wrapped deflate as CompressionStream('deflate'):

// lib/flatten.js: RGBA -> packed RGB, compositing over a background colour
export function flattenToRgb(rgba, bg = [255, 255, 255]) {
  const out = new Uint8Array((rgba.length / 4) * 3);
  for (let i = 0, j = 0; i < rgba.length; i += 4, j += 3) {
    const k = rgba[i + 3] / 255;
    out[j]     = Math.round(rgba[i]     * k + bg[0] * (1 - k));
    out[j + 1] = Math.round(rgba[i + 1] * k + bg[1] * (1 - k));
    out[j + 2] = Math.round(rgba[i + 2] * k + bg[2] * (1 - k));
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode
// lib/png.js (abridged)
export async function encodePngRgb(width, height, rgba, deflate, { bg } = {}) {
  const rgb = flattenToRgb(rgba, bg);
  const idat = await deflate(filterSub(rgb, width, height)); // filter type 1 per scanline
  const ihdr = new Uint8Array(13);
  const dv = new DataView(ihdr.buffer);
  dv.setUint32(0, width); dv.setUint32(4, height);
  ihdr[8] = 8; // bit depth
  ihdr[9] = 2; // colour type 2 = truecolour RGB, no alpha
  const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
  return concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', new Uint8Array(0))]);
}

export async function browserDeflate(bytes) {
  const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'));
  return new Uint8Array(await new Response(stream).arrayBuffer());
}
Enter fullscreen mode Exit fullscreen mode

chunk() is just length + type + data + CRC-32, and the CRC table is about ten lines. Applying the Sub filter to each scanline (store each byte minus the one three bytes to its left) keeps the files reasonably small without writing a full filter heuristic.

If CompressionStream isn't available, the code falls back to toBlob('image/png') on the opaque canvas. JPEG export uses toBlob('image/jpeg', 0.92), since JPEG has no alpha anyway.

Verify it. My tests read the IHDR back and assert that the colour type is 2 for every exported PNG:

export function readPngHeader(bytes) {
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  return { width: dv.getUint32(16), height: dv.getUint32(20), bitDepth: bytes[24], colorType: bytes[25] };
}
Enter fullscreen mode Exit fullscreen mode

You can run the same check on your own files with file screenshot.png. If it says "RGBA", the store may reject it.

3. One design, many aspect ratios

A 1320 × 2868 phone shot, a 2752 × 2064 landscape iPad and a 1024 × 500 feature graphic can't share one set of absolute coordinates. Each layout is a pure function from canvas size to boxes, with everything expressed as a fraction of the canvas:

const m  = Math.min(W, H) * 0.07;   // margin
const hs = Math.min(W, H) * 0.085;  // headline start size
const ss = Math.min(W, H) * 0.042;  // subline start size
Enter fullscreen mode Exit fullscreen mode

There are eight layouts: caption top, caption bottom, big number, split panorama, frameless, full bleed, two-column and minimal. The important rule is in adaptTemplate: if the canvas is noticeably wider than it is tall, a portrait layout would leave a tiny phone in a sea of background, so it switches to two columns:

export function adaptTemplate(template, W, H, isFeature = false) {
  if (isFeature) return 'feature';
  if (W / H >= 1.1 && template !== 'full-bleed') return 'two-column';
  return template;
}
Enter fullscreen mode Exit fullscreen mode

Because the layout code is pure (no DOM, no canvas), it runs under Node tests.

Text fitting works the same way. fitText greedily wraps words with a measure(text, size) callback (ctx.measureText in the browser), then steps the font size down by 3% of the starting size until the text fits the reserved box in at most four lines. Words longer than the box are split by character, which matters for German compounds and for languages without spaces.

4. Frames: draw your own

Device frames are where legal care matters. I drew plain, generic rounded frames as SVGs and recorded their geometry: outer size, a uniform bezel, screen size and corner radius. With a uniform bezel, rotating to landscape only means swapping width and height:

phone: { file: '/frames/frame-phone.svg', w: 1436, h: 2984, b: 58, sw: 1320, sh: 2868, r: 118 },
Enter fullscreen mode Exit fullscreen mode

Manufacturer bezel files come with their own licence terms. Apple's, for example, are licensed to Apple Developer Program members for marketing their own apps. So the generator doesn't ship them. Users can load a bezel PNG they're licensed to use, and the code finds the transparent screen hole by flood-filling outward from the centre over low-alpha pixels, then takes the bounding box:

export function findScreenHole(alpha, width, height, threshold = 16) {
  const cx = width >> 1, cy = height >> 1;
  if (alpha[cy * width + cx] >= threshold) return null; // centre is opaque: not a bezel
  // iterative 4-way flood fill with an explicit stack, tracking min/max x/y
  ...
}
Enter fullscreen mode Exit fullscreen mode

Use an explicit stack rather than recursion. A 13" iPad hole is several million pixels, and recursion would blow the call stack.

Google Play presets default to no frame, because Google's guidelines advise avoiding device imagery.

5. Name files so uploading is boring

The ZIP layout mirrors how you upload: by store, then by size, then numbered slides.

app-store/iphone-duo-inner-portrait/01.png
app-store/iphone-1320x2868/01.png
app-store/ipad-2752x2064/01.png
google-play/phone-1080x1920/01.png
google-play/feature-graphic-1024x500.png
Enter fullscreen mode Exit fullscreen mode

When captions are translated, each language gets its own subfolder (.../de-DE/01.png). The ZIP is generated with JSZip using compression: 'STORE'. PNG and JPEG data is already compressed, so deflating it again costs time and saves almost nothing.

Before the export starts, a pure countFiles() works out the total. For example, 6 targets × 2 slides + 1 feature graphic = 13 files. That number drives the progress bar. Between files the loop awaits setTimeout(0), so the progress text actually gets painted during a long export.

Pitfalls checklist

  • Alpha channel in "opaque" PNGs. Check the colour type, not just how the image looks.
  • Stretching instead of re-laying out. Scale a phone design to an iPad and it looks wrong. Lay out per size.
  • Google Play's 2:1 rule. The long side can be at most twice the short side. Very tall phone captures can break it.
  • Taglines on Play. Google recommends keeping text under 20% of the image and avoiding words like "Best", "#1" or "Free", and calls to action like "Download now". Keep captions descriptive.
  • Feature graphic cut-off zones. Google asks you to keep key elements toward the centre but doesn't publish exact margins. I use a conservative guide box in the preview only.
  • Status bar clutter. Google's guidelines ask for no carrier names or notifications, with full battery, Wi-Fi and signal icons. Fix this in the simulator before you capture.
  • Stale size lists. Re-check the official pages whenever new hardware is announced, and keep a "verified on" date next to your presets.

Try it

If you'd rather not build this yourself, the generator is live at screenshots.hueflo.com. The editor and every export are free, with no sign-up and no watermark, and your screenshots stay in your browser. It's still being built, so feedback in the comments is welcome. I'd especially like to hear which sizes or layouts you're missing.

How do you handle store screenshots today: fastlane snapshot, a Figma template, or a script of your own?

Top comments (0)