app-store-screenshots
Use when building App Store or Google Play screenshot pages, generating exportable marketing screenshots for iOS and/or Android apps, or creating programmatic screenshot generators with Next.js. Triggers on app store, play store, screenshots, marketing assets, html-to-image, phone mockup, android screenshots, feature graphic.
git clone https://github.com/ParthJadhav/app-store-screenshots
T=$(mktemp -d) && git clone --depth=1 https://github.com/ParthJadhav/app-store-screenshots "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/app-store-screenshots" ~/.claude/skills/parthjadhav-app-store-screenshots-app-store-screenshots && rm -rf "$T"
skills/app-store-screenshots/SKILL.mdApp Store & Google Play Screenshots Generator
Overview
Build a Next.js page that renders App Store and Google Play screenshots as advertisements (not UI showcases) and exports them via
html-to-image at Apple's and Google's required resolutions. Screenshots are the single most important conversion asset on both stores.
Supported devices out of the box:
- iPhone (portrait) — Apple App Store
- iPad (portrait) — Apple App Store
- Android Phone (portrait) — Google Play
- Android Tablet 7" (portrait + landscape) — Google Play
- Android Tablet 10" (portrait + landscape) — Google Play
- Feature Graphic (landscape banner, 1024×500) — Google Play store listing header
Core Principle
Screenshots are advertisements, not documentation. Every screenshot sells one idea. If you're showing UI, you're doing it wrong — you're selling a feeling, an outcome, or killing a pain point.
Step 1: Ask the User These Questions
Before writing ANY code, ask the user all of these. Do not proceed until you have answers:
Required
- App screenshots — "Where are your app screenshots? (PNG files of actual device captures)"
- App icon — "Where is your app icon PNG?"
- Brand colors — "What are your brand colors? (accent color, text color, background preference)"
- Font — "What font does your app use? (or what font do you want for the screenshots?)"
- Feature list — "List your app's features in priority order. What's the #1 thing your app does?"
- Number of slides — "How many screenshots do you want? (Apple allows up to 10, Google Play up to 8)"
- Style direction — "What style do you want? Examples: warm/organic, dark/moody, clean/minimal, bold/colorful, gradient-heavy, flat. Share App Store screenshot references if you have any."
Optional
- Target stores — "Are you targeting Apple App Store only, Google Play only, or both? This determines which devices we generate screenshots for."
- iPad screenshots — "Do you also have iPad screenshots? If so, we'll generate iPad App Store screenshots too (recommended for universal apps)."
- Android tablet screenshots — "Do you have Android tablet screenshots? If yes, what tablet sizes — 7" and/or 10"? Do you have them in portrait, landscape, or both orientations?"
- Feature Graphic — "Do you want a Google Play Feature Graphic (1024×500 banner shown at the top of your Play Store listing)? This is separate from phone screenshots."
- Component assets — "Do you have any UI element PNGs (cards, widgets, etc.) you want as floating decorations? If not, that's fine — we'll skip them."
- Localized screenshots — "Do you want screenshots in multiple languages? This helps your listing rank in regional App Stores even if your app is English-only. If yes: which languages? (e.g. en, de, es, pt, ja, ar, he)"
- Theme preset system — "Do you want one art direction, or reusable visual themes (for example: clean-light, dark-bold, warm-editorial) so you can swap screenshot looks quickly?"
- Additional instructions — "Any specific requirements, constraints, or preferences?"
Derived from answers (do NOT ask — decide yourself)
Based on the user's style direction, brand colors, and app aesthetic, decide:
- Background style: gradient direction, colors, whether light or dark base
- Decorative elements: blobs, glows, geometric shapes, or none — match the style
- Dark vs light slides: how many of each, which features suit dark treatment
- Typography treatment: weight, tracking, line height — match the brand personality
- Color palette: derive text colors, secondary colors, shadow tints from the brand colors
- Theme preset names: turn vague style requests into reusable theme ids the user can switch between
- RTL behavior: if any locale is RTL (
,ar
,he
,fa
), mirror layout intentionally instead of just translating the textur - Landscape slide layouts: for tablet landscape slides, use caption-left + device-right composition (never try to fit two tablets side-by-side in landscape — there's not enough horizontal room)
IMPORTANT: If the user gives additional instructions at any point during the process, follow them. User instructions always override skill defaults.
Step 2: Set Up the Project
Detect Package Manager
Check what's available, use this priority: bun > pnpm > yarn > npm
# Check in order which bun && echo "use bun" || which pnpm && echo "use pnpm" || which yarn && echo "use yarn" || echo "use npm"
Scaffold (if no existing Next.js project)
# With bun: bunx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*" bun add html-to-image # With pnpm: pnpx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*" pnpm add html-to-image # With yarn: yarn create next-app . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*" yarn add html-to-image # With npm: npx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*" npm install html-to-image
Copy the Phone Mockup
The skill includes a pre-measured iPhone mockup at
mockup.png (co-located with this SKILL.md). Copy it to the project's public/ directory. All other device frames (Android Phone, Android Tablets, iPad) are rendered with CSS — no additional mockup PNGs needed.
File Structure
iPhone-only app (default)
project/ ├── public/ │ ├── mockup.png # iPhone frame (included with skill) │ ├── app-icon.png # User's app icon │ └── screenshots/ │ ├── en/ │ │ ├── home.png │ │ ├── feature-1.png │ │ └── ... │ ├── de/ │ └── {locale}/ ├── src/app/ │ ├── layout.tsx # Font setup │ └── page.tsx # The screenshot generator (single file) └── package.json
If iPad screenshots are localized too, mirror the same locale structure:
└── screenshots-ipad/ ├── en/ ├── de/ └── {locale}/
Single-language apps can omit the locale folder entirely — paths become
screenshots/home.png.
Multi-platform app (iOS + Android)
When the user needs both Apple and Android screenshots, use a platform-based structure so every device's images are clearly separated:
└── screenshots/ ├── apple/ │ ├── iphone/ │ │ ├── en/ │ │ └── {locale}/ │ └── ipad/ │ ├── en/ │ └── {locale}/ └── android/ ├── phone/ │ ├── en/ │ └── {locale}/ ├── tablet-7/ │ ├── portrait/ │ │ └── {locale}/ │ └── landscape/ │ └── {locale}/ └── tablet-10/ ├── portrait/ │ └── {locale}/ └── landscape/ └── {locale}/
Only create subdirectories for devices the user actually has screenshots for. An empty directory will cause broken image placeholders in the generator.
Use the iPhone-only structure by default. Switch to the platform-based structure only when the user confirms they're targeting Android as well.
The entire generator is a single
file. No routing, no extra layouts, no API routes.page.tsx
Multi-language: Locale Select
Add a
LOCALES array and a <select> locale picker to the toolbar. Every slide src uses a base variable — no hardcoded locale paths:
const LOCALES = ["en", "de", "es", "tr"] as const; // use whatever langs were defined type Locale = typeof LOCALES[number]; // In ScreenshotsPage: const [locale, setLocale] = useState<Locale>("en"); // base is derived per-device from locale: const base = (platform: string) => `/screenshots/${platform}/${locale}`; // Toolbar: <select value={locale} onChange={e => setLocale(e.target.value as Locale)}> {LOCALES.map(l => <option key={l} value={l}>{l.toUpperCase()}</option>)} </select> // In every slide — unchanged between single and multi-language: <Phone src={`${base("apple/iphone")}/home.png`} alt="Home" />
Use a
<select> rather than inline tabs for locale — it scales cleanly to many languages without overflowing the toolbar.
Theme Presets
const THEMES = { "clean-light": { bg: "#F6F1EA", fg: "#171717", accent: "#5B7CFA", muted: "#6B7280" }, "dark-bold": { bg: "#0B1020", fg: "#F8FAFC", accent: "#8B5CF6", muted: "#94A3B8" }, "warm-editorial": { bg: "#F7E8DA", fg: "#2B1D17", accent: "#D97706", muted: "#7C5A47" }, } as const; type ThemeId = keyof typeof THEMES; const [themeId, setThemeId] = useState<ThemeId>("clean-light"); const theme = THEMES[themeId];
Use theme tokens everywhere instead of hardcoded colors.
Font Setup
// src/app/layout.tsx import { YourFont } from "next/font/google"; const font = YourFont({ subsets: ["latin"] }); export default function Layout({ children }: { children: React.ReactNode }) { return <html><body className={font.className}>{children}</body></html>; }
Step 3: Plan the Slides
Screenshot Framework (Narrative Arc)
Adapt this framework to the user's requested slide count. Not all slots are required — pick what fits:
| Slot | Purpose | Notes |
|---|---|---|
| #1 | Hero / Main Benefit | App icon + tagline + home screen. This is the ONLY one most people see. |
| #2 | Differentiator | What makes this app unique vs competitors |
| #3 | Ecosystem | Widgets, extensions, watch — beyond the main app. Skip if N/A. |
| #4+ | Core Features | One feature per slide, most important first |
| 2nd to last | Trust Signal | Identity/craft — "made for people who [X]" |
| Last | More Features | Pills listing extras + coming soon. Skip if few features. |
Rules:
- Each slide sells ONE idea. Never two features on one slide.
- Vary layouts across slides — never repeat the same template structure.
- Include 1-2 contrast slides (inverted bg) for visual rhythm.
- Landscape tablets: use caption-left + device-right layout. The wide canvas rewards asymmetric composition. Never try two devices side-by-side in landscape — there's not enough room.
Step 4: Write Copy FIRST
Get all headlines approved before building layouts. Bad copy ruins good design.
The Iron Rules
- One idea per headline. Never join two things with "and."
- Short, common words. 1-2 syllables. No jargon unless it's domain-specific.
- 3-5 words per line. Must be readable at thumbnail size in the App Store.
- Line breaks are intentional. Control where lines break with
.<br />
Three Approaches (pick one per slide)
| Type | What it does | Example |
|---|---|---|
| Paint a moment | You picture yourself doing it | "Check your coffee without opening the app." |
| State an outcome | What your life looks like after | "A home for every coffee you buy." |
| Kill a pain | Name a problem and destroy it | "Never waste a great bag of coffee." |
What NEVER Works
- Feature lists as headlines: "Log every item with tags, categories, and notes"
- Two ideas joined by "and": "Track X and never miss Y"
- Vague aspirational: "Every item, tracked"
- Marketing buzzwords: "AI-powered tips" (unless it's actually AI)
Bad-to-Better Headline Examples
| Weak | Better | Why it wins |
|---|---|---|
| Track habits and stay motivated | Keep your streak alive | one idea, faster to parse |
| Organize tasks with AI summaries | Turn notes into next steps | outcome-first, less jargon |
| Save recipes with tags and favorites | Find dinner fast | sells the benefit, not the UI |
| Manage budgets and never miss payments | See where money goes | cleaner promise, no dual claim |
Copy Process
- Write 3 options per slide using the three approaches
- Read each at arm's length — if you can't parse it in 1 second, it's too complex
- Check: does each line have 3-5 words? If not, adjust line breaks
- Present options to the user with reasoning for each
Example Prompt Shapes
If the user gives a weak or underspecified request, reshape it internally into something like:
Build App Store screenshots for my habit tracker. The app helps people stay consistent with simple daily routines. I want 6 slides, clean/minimal style, warm neutrals, and a calm premium feel.
Generate App Store screenshots for my personal finance app. The app's main strengths are fast expense capture, clear monthly trends, and shared budgets. I want a sharp, modern style with high contrast and 7 slides.
Create exportable App Store screenshots for my AI note-taking app. The core value is turning messy voice notes into clean summaries and action items. I want bold copy, dark backgrounds, and a polished tech-forward look.
The pattern is:
- app category + core outcome
- top features in priority order
- desired slide count
- style direction
Localization Rules
- Do not literally translate headlines if the result becomes long or awkward — re-write for the target market.
- Re-check line breaks per locale; German, French, and Portuguese often need shorter claims.
- For RTL languages (
,ar
,he
,fa
), setur
on the canvas and mirror asymmetric layouts intentionally.dir="rtl"
Reference Apps for Copy Style
- Raycast — specific, descriptive, one concrete value per slide
- Turf — ultra-simple action verbs, conversational
- Mela / Notion — warm, minimal, elegant
Step 5: Build the Page
Architecture
page.tsx ├── Constants (canvas dimensions, export sizes, frame ratios) ├── Width formula functions (phoneW, tabletPW, tabletLW, ipadW) ├── LOCALES / RTL_LOCALES / THEMES / COPY_BY_LOCALE ├── Image preload cache (preloadAllImages + img() helper) ├── Device frame components: │ ├── Phone — iPhone (mockup.png + pre-measured overlay) │ ├── AndroidPhone — Android phone (CSS-only) │ ├── AndroidTabletP — Android tablet portrait (CSS-only) │ ├── AndroidTabletL — Android tablet landscape (CSS-only) │ └── IPad — iPad (CSS-only) ├── Caption component (label + headline, scales from canvasW) ├── Decorative components (blobs, glows — based on style direction) ├── Slide components (makeSlide1..N factories for portrait, │ makeTabLSlide1..N factories for landscape) ├── Slide registries (IPHONE_SLIDES, ANDROID_SLIDES, ANDROID_7P_SLIDES, │ ANDROID_7L_SLIDES, ANDROID_10P_SLIDES, ANDROID_10L_SLIDES, IPAD_SLIDES) ├── ScreenshotPreview — ResizeObserver scaling + hover export └── ScreenshotsPage — grid + toolbar + export logic
Canvas Dimensions
Design at the largest required resolution for each device category. Smaller sizes are achieved by re-rendering at the target resolution on export.
// Apple const W = 1320; const H = 2868; // iPhone (6.9" — largest required) const IPAD_W = 2064; const IPAD_H = 2752; // iPad 13" — largest required // Android phone const AW = 1080; const AH = 1920; // Android phone // Android tablet — portrait const AT7P_W = 1200; const AT7P_H = 1920; // 7" portrait const AT10P_W = 1600; const AT10P_H = 2560; // 10" portrait // Android tablet — landscape const AT7L_W = 1920; const AT7L_H = 1200; // 7" landscape const AT10L_W = 2560; const AT10L_H = 1600; // 10" landscape // Feature Graphic const FGW = 1024; const FGH = 500;
Export Sizes
iPhone (Apple required, portrait)
const IPHONE_SIZES = [ { label: '6.9"', w: 1320, h: 2868 }, { label: '6.5"', w: 1284, h: 2778 }, { label: '6.3"', w: 1206, h: 2622 }, { label: '6.1"', w: 1125, h: 2436 }, ] as const;
iPad (Apple required, portrait)
const IPAD_SIZES = [ { label: '13" iPad', w: 2064, h: 2752 }, { label: '12.9" iPad Pro', w: 2048, h: 2732 }, ] as const;
Android (Google Play recommended)
const ANDROID_SIZES = [{ label: "Phone", w: 1080, h: 1920 }] as const; const ANDROID_7P_SIZES = [{ label: '7" Portrait', w: 1200, h: 1920 }] as const; const ANDROID_7L_SIZES = [{ label: '7" Landscape', w: 1920, h: 1200 }] as const; const ANDROID_10P_SIZES= [{ label: '10" Portrait', w: 1600, h: 2560 }] as const; const ANDROID_10L_SIZES= [{ label: '10" Landscape', w: 2560, h: 1600 }] as const; const FG_SIZES = [{ label: "Feature Graphic", w: 1024, h: 500 }] as const;
Device Type
type Device = "iphone" | "android" | "android-7" | "android-10" | "ipad" | "feature-graphic"; type Orientation = "portrait" | "landscape";
Frame Aspect Ratios
const MK_RATIO = 1022 / 2082; // iPhone mockup (width/height) const TAB_P_RATIO = 0.667; // tablet portrait frame (5:8 screen) const TAB_L_RATIO = 1.5; // tablet landscape frame (8:5 screen) const IPAD_RATIO = 0.770; // iPad frame (770/1000)
Width Formula Functions
These functions determine how wide to render a device frame relative to the canvas. They auto-scale so the device fills the canvas proportionally regardless of canvas aspect ratio:
type WidthFn = (cW: number, cH: number) => number; // Returns a fraction of canvas width (0–1) function phoneW(cW: number, cH: number, clamp = 0.84) { return Math.min(clamp, 0.72 * (cH / cW) * MK_RATIO); } function phoneW2(cW: number, cH: number) { return phoneW(cW, cH, 0.66); } // smaller, for two-phone slides function tabletPW(cW: number, cH: number, clamp = 0.80) { return Math.min(clamp, 0.72 * (cH / cW) * TAB_P_RATIO); } function tabletPW2(cW: number, cH: number) { return tabletPW(cW, cH, 0.64); } function tabletLW(cW: number, cH: number, clamp = 0.62) { return Math.min(clamp, 0.75 * (cH / cW) * TAB_L_RATIO); } function ipadW(cW: number, cH: number, clamp = 0.75) { return Math.min(clamp, 0.72 * (cH / cW) * IPAD_RATIO); } function ipadW2(cW: number, cH: number) { return ipadW(cW, cH, 0.60); }
Usage:
width: \${phoneW(cW, cH) * 100}%``
Rendering Strategy
Each screenshot is designed at full resolution. Two copies exist:
- Preview: CSS
viatransform: scale()
to fit a grid cardResizeObserver - Export: Offscreen at
at true resolutionposition: absolute; left: -9999px
Critical: Wrap the entire page in
overflowX: "hidden" to prevent offscreen export elements from causing horizontal scroll:
<div style={{ minHeight: "100vh", background: "#f3f4f6", position: "relative", overflowX: "hidden" }}>
Device Frame Components
iPhone (PNG mockup)
The included
mockup.png has these pre-measured values:
const MK_W = 1022; const MK_H = 2082; const SC_L = (52 / MK_W) * 100; // screen left % const SC_T = (46 / MK_H) * 100; // screen top % const SC_W = (918 / MK_W) * 100; // screen width % const SC_H = (1990 / MK_H) * 100; // screen height % const SC_RX = (126 / 918) * 100; // border-radius x % const SC_RY = (126 / 1990) * 100; // border-radius y %
function Phone({ src, alt, style }: { src: string; alt: string; style?: React.CSSProperties }) { return ( <div style={{ position: "relative", aspectRatio: `${MK_W}/${MK_H}`, ...style }}> <img src={img("/mockup.png")} alt="" style={{ display: "block", width: "100%", height: "100%" }} draggable={false} /> <div style={{ position: "absolute", zIndex: 10, overflow: "hidden", left: `${SC_L}%`, top: `${SC_T}%`, width: `${SC_W}%`, height: `${SC_H}%`, borderRadius: `${SC_RX}% / ${SC_RY}%`, }}> <img src={src} alt={alt} style={{ display: "block", width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} draggable={false} /> </div> </div> ); }
Android Phone (CSS-only)
function AndroidPhone({ src, alt, style }: { src: string; alt: string; style?: React.CSSProperties }) { return ( <div style={{ position: "relative", aspectRatio: "9/19.5", ...style }}> <div style={{ width: "100%", height: "100%", borderRadius: "8% / 4%", background: "linear-gradient(160deg, #2a2a2e 0%, #18181b 100%)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08), 0 8px 40px rgba(0,0,0,0.55)", position: "relative", overflow: "hidden", }}> {/* Punch-hole camera */} <div style={{ position: "absolute", top: "1.5%", left: "50%", transform: "translateX(-50%)", width: "3%", height: "1.4%", borderRadius: "50%", background: "#0d0d0f", border: "1px solid rgba(255,255,255,0.06)", zIndex: 20, }} /> {/* Screen */} <div style={{ position: "absolute", left: "3.5%", top: "2%", width: "93%", height: "96%", borderRadius: "5.5% / 2.6%", overflow: "hidden", background: "#000", }}> <img src={src} alt={alt} style={{ display: "block", width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} draggable={false} /> </div> </div> </div> ); }
Android Tablet — Portrait (CSS-only)
function AndroidTabletP({ src, alt, style }: { src: string; alt: string; style?: React.CSSProperties }) { return ( <div style={{ position: "relative", aspectRatio: "5/8", ...style }}> <div style={{ width: "100%", height: "100%", borderRadius: "4.5% / 2.8%", background: "linear-gradient(160deg, #2a2a2e 0%, #18181b 100%)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08), 0 8px 48px rgba(0,0,0,0.6)", position: "relative", overflow: "hidden", }}> {/* Camera dot */} <div style={{ position: "absolute", top: "1.2%", left: "50%", transform: "translateX(-50%)", width: "1.4%", height: "0.88%", borderRadius: "50%", background: "#0d0d0f", border: "1px solid rgba(255,255,255,0.07)", zIndex: 20, }} /> {/* Bezel highlight */} <div style={{ position: "absolute", inset: 0, borderRadius: "4.5% / 2.8%", border: "1px solid rgba(255,255,255,0.05)", pointerEvents: "none", zIndex: 15, }} /> {/* Screen */} <div style={{ position: "absolute", left: "3.5%", top: "2.2%", width: "93%", height: "95.6%", borderRadius: "2.5% / 1.6%", overflow: "hidden", background: "#000", }}> <img src={src} alt={alt} style={{ display: "block", width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} draggable={false} /> </div> </div> </div> ); }
Android Tablet — Landscape (CSS-only)
Same as portrait but with a rotated aspect ratio and camera on the left side:
function AndroidTabletL({ src, alt, style }: { src: string; alt: string; style?: React.CSSProperties }) { return ( <div style={{ position: "relative", aspectRatio: "8/5", ...style }}> <div style={{ width: "100%", height: "100%", borderRadius: "2.8% / 4.5%", background: "linear-gradient(160deg, #2a2a2e 0%, #18181b 100%)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08), 0 8px 48px rgba(0,0,0,0.6)", position: "relative", overflow: "hidden", }}> {/* Camera dot — left side in landscape */} <div style={{ position: "absolute", left: "1.2%", top: "50%", transform: "translateY(-50%)", width: "0.88%", height: "1.4%", borderRadius: "50%", background: "#0d0d0f", border: "1px solid rgba(255,255,255,0.07)", zIndex: 20, }} /> <div style={{ position: "absolute", inset: 0, borderRadius: "2.8% / 4.5%", border: "1px solid rgba(255,255,255,0.05)", pointerEvents: "none", zIndex: 15, }} /> {/* Screen */} <div style={{ position: "absolute", left: "2.2%", top: "3.5%", width: "95.6%", height: "93%", borderRadius: "1.6% / 2.5%", overflow: "hidden", background: "#000", }}> <img src={src} alt={alt} style={{ display: "block", width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} draggable={false} /> </div> </div> </div> ); }
iPad (CSS-only)
Critical dimension: Frame aspect ratio must be
770/1000 so the inner screen (92% × 94.4%) matches the 3:4 aspect ratio of iPad screenshots.
function IPad({ src, alt, style }: { src: string; alt: string; style?: React.CSSProperties }) { return ( <div style={{ position: "relative", aspectRatio: "770/1000", ...style }}> <div style={{ width: "100%", height: "100%", borderRadius: "5% / 3.6%", background: "linear-gradient(180deg, #2C2C2E 0%, #1C1C1E 100%)", position: "relative", overflow: "hidden", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.1), 0 8px 40px rgba(0,0,0,0.6)", }}> <div style={{ position: "absolute", top: "1.2%", left: "50%", transform: "translateX(-50%)", width: "0.9%", height: "0.65%", borderRadius: "50%", background: "#111113", border: "1px solid rgba(255,255,255,0.08)", zIndex: 20, }} /> <div style={{ position: "absolute", inset: 0, borderRadius: "5% / 3.6%", border: "1px solid rgba(255,255,255,0.06)", pointerEvents: "none", zIndex: 15, }} /> <div style={{ position: "absolute", left: "4%", top: "2.8%", width: "92%", height: "94.4%", borderRadius: "2.2% / 1.6%", overflow: "hidden", background: "#000", }}> <img src={src} alt={alt} style={{ display: "block", width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} draggable={false} /> </div> </div> </div> ); }
Slide Factory Pattern
Instead of writing separate slide components for every device, use factory functions. Each factory accepts a device component, its width function, the screenshot base path, and the frame ratio:
type SlideProps = { cW: number; cH: number; locale: string }; type SlideDef = { id: string; component: (p: SlideProps) => JSX.Element }; type PhoneComp = (p: { src: string; alt: string; style?: React.CSSProperties }) => JSX.Element; function makeSlide1( PhoneComp: PhoneComp, widthFn: WidthFn, basePath: string, _frameRatio: number, ): SlideDef { return { id: "hero", component: ({ cW, cH }) => { const fw = widthFn(cW, cH) * 100; return ( <div style={{ width: "100%", height: "100%", position: "relative", background: "...", overflow: "hidden" }}> <Caption cW={cW} label="YOUR APP" headline={<>"Sell one<br />idea here."</>} /> <PhoneComp src={img(`/${basePath}/home.png`)} alt="Home" style={{ position: "absolute", bottom: 0, width: `${fw}%`, left: "50%", transform: `translateX(-50%) translateY(13%)`, }} /> </div> ); }, }; }
Build
makeSlide2..N with the same signature. Then build registries:
const mkTabP = (base: string) => [ makeSlide1(AndroidTabletP, tabletPW, base, TAB_P_RATIO), makeSlide2(AndroidTabletP, tabletPW, base, TAB_P_RATIO), // ... ]; const mkTabL = (base: string) => [ makeTabLSlide1(AndroidTabletL, tabletLW, base), makeTabLSlide2(AndroidTabletL, tabletLW, base), // ... ]; const IPHONE_SLIDES = [makeSlide1(Phone, phoneW, "screenshots/apple/iphone", MK_RATIO), ...]; const ANDROID_SLIDES = [makeSlide1(AndroidPhone, phoneW, "screenshots/android/phone", MK_RATIO), ...]; const ANDROID_7P_SLIDES = mkTabP("screenshots/android/tablet-7/portrait"); const ANDROID_7L_SLIDES = mkTabL("screenshots/android/tablet-7/landscape"); const ANDROID_10P_SLIDES = mkTabP("screenshots/android/tablet-10/portrait"); const ANDROID_10L_SLIDES = mkTabL("screenshots/android/tablet-10/landscape"); const IPAD_SLIDES = [makeSlide1(IPad, ipadW, "screenshots/apple/ipad", IPAD_RATIO), ...];
Landscape Slide Layout
Landscape tablet canvases are wide (e.g. 2560×1600). Use a caption-left + device-right layout. Never try two devices side-by-side — there isn't enough room.
function makeTabLSlide1(PhoneComp: PhoneComp, widthFn: WidthFn, basePath: string): SlideDef { return { id: "hero-landscape", component: ({ cW, cH }) => { const fw = widthFn(cW, cH) * 100; return ( <div style={{ width: "100%", height: "100%", position: "relative", background: "...", overflow: "hidden" }}> {/* Caption — left 34% of canvas */} <div style={{ position: "absolute", top: "50%", left: "5%", width: "34%", transform: "translateY(-50%)" }}> <Caption cW={cW} label="FEATURE" headline={<>"One idea<br />per slide."</>} /> </div> {/* Device — right side */} <PhoneComp src={img(`/${basePath}/home.png`)} alt="Home" style={{ position: "absolute", right: "-3%", top: "50%", width: `${fw}%`, transform: "translateY(-50%)", }} /> </div> ); }, }; }
Feature Graphic Component
The Feature Graphic is a 1024×500 landscape banner shown at the top of the Google Play store listing. It has no device frame — it's a pure graphic with the app name, tagline, icon, and decorative elements.
function FeatureGraphicSlide({ cW, cH }: { cW: number; cH: number }) { return ( <div style={{ width: "100%", height: "100%", position: "relative", overflow: "hidden", background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)", display: "flex", alignItems: "center", justifyContent: "space-between", padding: `0 ${cW * 0.06}px`, }}> {/* Left: app icon + name + tagline */} <div style={{ display: "flex", alignItems: "center", gap: cW * 0.03 }}> <img src={img("/app-icon.png")} alt="App Icon" style={{ width: cW * 0.12, height: cW * 0.12, borderRadius: cW * 0.022 }} draggable={false} /> <div> <div style={{ fontSize: cW * 0.05, fontWeight: 800, color: "#fff", lineHeight: 1.1 }}>AppName</div> <div style={{ fontSize: cW * 0.025, color: "rgba(255,255,255,0.7)", marginTop: cW * 0.008 }}>Your tagline here.</div> </div> </div> {/* Right: decorative / supporting visual */} </div> ); }
Device Resolution Dispatch
In the main component, derive canvas dimensions, export sizes, and slide registry from the current device + orientation state:
const { cW, cH, currentSizes, slides } = (() => { if (device === "android-7") { return orientation === "landscape" ? { cW: AT7L_W, cH: AT7L_H, currentSizes: ANDROID_7L_SIZES, slides: ANDROID_7L_SLIDES } : { cW: AT7P_W, cH: AT7P_H, currentSizes: ANDROID_7P_SIZES, slides: ANDROID_7P_SLIDES }; } if (device === "android-10") { return orientation === "landscape" ? { cW: AT10L_W, cH: AT10L_H, currentSizes: ANDROID_10L_SIZES, slides: ANDROID_10L_SLIDES } : { cW: AT10P_W, cH: AT10P_H, currentSizes: ANDROID_10P_SIZES, slides: ANDROID_10P_SLIDES }; } if (device === "android") return { cW: AW, cH: AH, currentSizes: ANDROID_SIZES, slides: ANDROID_SLIDES }; if (device === "ipad") return { cW: IPAD_W, cH: IPAD_H, currentSizes: IPAD_SIZES, slides: IPAD_SLIDES }; if (device === "feature-graphic") return { cW: FGW, cH: FGH, currentSizes: FG_SIZES, slides: [FG_SLIDE] }; return { cW: W, cH: H, currentSizes: IPHONE_SIZES, slides: IPHONE_SLIDES }; })();
Toolbar Layout
The toolbar has two sections: a scrollable controls area (left,
flex: 1) and a fixed export button (right, always visible). Never wrap them in a single scrollable row — the button must always be reachable.
{/* Toolbar */} <div style={{ position: "sticky", top: 0, zIndex: 50, background: "white", borderBottom: "1px solid #e5e7eb", display: "flex", alignItems: "center" }}> {/* Scrollable controls */} <div style={{ flex: 1, display: "flex", alignItems: "center", gap: 10, padding: "10px 16px", overflowX: "auto", minWidth: 0 }}> <span style={{ fontWeight: 700, fontSize: 14, whiteSpace: "nowrap" }}>My App · Screenshots</span> {/* Locale */} <select value={locale} onChange={e => setLocale(e.target.value as Locale)} style={{ fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 6, padding: "5px 10px" }}> {LOCALES.map(l => <option key={l} value={l}>{l.toUpperCase()}</option>)} </select> {/* Device tabs */} <div style={{ display: "flex", gap: 4, background: "#f3f4f6", borderRadius: 8, padding: 4, flexShrink: 0 }}> {(["iphone", "android", "ipad", "feature-graphic"] as Device[]).map(d => ( <button key={d} onClick={() => { setDevice(d); setSizeIdx(0); }} style={{ padding: "4px 14px", borderRadius: 6, border: "none", cursor: "pointer", fontSize: 12, fontWeight: 600, whiteSpace: "nowrap", background: device === d ? "white" : "transparent", color: device === d ? "#2563eb" : "#6b7280" }}> {d === "iphone" ? "iPhone" : d === "android" ? "Android" : d === "ipad" ? "iPad" : "Feature Graphic"} </button> ))} {/* Android tablet dropdown — inside the device tab group */} <select value={isTablet ? device : ""} onChange={e => { if (e.target.value) { setDevice(e.target.value as Device); setSizeIdx(0); } }} style={{ fontSize: 12, border: "none", borderRadius: 6, padding: "4px 10px", cursor: "pointer", background: isTablet ? "white" : "transparent", color: isTablet ? "#2563eb" : "#6b7280" }}> <option value="" disabled>Android Tab.</option> <option value="android-7">Android 7"</option> <option value="android-10">Android 10"</option> </select> </div> {/* Orientation — tablets only */} {isTablet && ( <div style={{ display: "flex", gap: 4, background: "#f3f4f6", borderRadius: 8, padding: 4, flexShrink: 0 }}> {(["portrait", "landscape"] as Orientation[]).map(o => ( <button key={o} onClick={() => { setOrientation(o); setSizeIdx(0); }} style={{ padding: "4px 12px", borderRadius: 6, border: "none", cursor: "pointer", fontSize: 12, fontWeight: 600, background: orientation === o ? "white" : "transparent", color: orientation === o ? "#2563eb" : "#6b7280" }}> {o === "portrait" ? "Portrait ↕" : "Landscape ↔"} </button> ))} </div> )} {/* Export size */} {device !== "feature-graphic" && ( <select value={sizeIdx} onChange={e => setSizeIdx(Number(e.target.value))} style={{ fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 6, padding: "4px 10px" }}> {currentSizes.map((s, i) => <option key={i} value={i}>{s.label} — {s.w}×{s.h}</option>)} </select> )} </div> {/* Export button — always at right edge, never scrolls away */} <div style={{ flexShrink: 0, padding: "10px 16px", borderLeft: "1px solid #e5e7eb" }}> <button onClick={exportAll} disabled={!!exporting} style={{ padding: "7px 20px", background: exporting ? "#93c5fd" : "#2563eb", color: "white", border: "none", borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: exporting ? "default" : "pointer", whiteSpace: "nowrap" }}> {exporting ? `Exporting… ${exporting}` : "Export All"} </button> </div> </div>
isTablet helper:
const isTablet = device === "android-7" || device === "android-10";
Typography (Resolution-Independent)
All sizing relative to canvas width
cW:
| Element | Size | Weight | Line Height |
|---|---|---|---|
| Category label | | 600 | default |
| Headline | to | 700 | 1.0 |
| Hero headline | | 700 | 0.92 |
| Feature Graphic name | | 800 | 1.1 |
Phone Placement Patterns (Portrait)
Vary across slides — NEVER use the same layout twice in a row:
Centered device (hero, single-feature):
bottom: 0, width: "82-86%" (phone) / "70-75%" (tablet) / "65-70%" (iPad) left: "50%", transform: "translateX(-50%) translateY(13%)"
Two devices layered (comparison):
Back: left: "-8%", width: "65%", rotate(-4deg), opacity: 0.55 Front: right: "-4%", width: "82%", translateY(10%)
Landscape tablet (always caption-left + device-right):
Caption: position: absolute, top: 50%, left: 5%, width: 34%, transform: translateY(-50%) Device: position: absolute, right: "-3%", top: 50%, width: fw%, transform: translateY(-50%)
"More Features" Slide (Optional)
Dark/contrast background with app icon, headline ("And so much more."), and feature pills. Can include a "Coming Soon" section with dimmer pills. Works identically across all device types.
Step 6: Export
Why html-to-image, NOT html2canvas
html2canvas breaks on CSS filters, gradients, drop-shadow, backdrop-filter, and complex clipping. html-to-image uses native browser SVG serialization — handles all CSS faithfully.
Pre-load Images as Data URIs (CRITICAL)
html-to-image clones the DOM into an SVG <foreignObject>. During cloning it re-fetches every <img> src. These re-fetches are non-deterministic — some hit the browser cache, some silently fail, causing transparent/black rectangles in exports.
Fix: Convert all images to base64 data URIs at page load. Use those as
src everywhere.
const IMAGE_PATHS = [ "/mockup.png", "/app-icon.png", "/screenshots/apple/iphone/en/home.png", // ... all images used in any slide across all devices/locales ]; const imageCache: Record<string, string> = {}; async function preloadAllImages() { await Promise.all(IMAGE_PATHS.map(async (path) => { const resp = await fetch(path); const blob = await resp.blob(); const dataUrl = await new Promise<string>((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); imageCache[path] = dataUrl; })); } // Use in every <img> src: function img(path: string): string { return imageCache[path] || path; }
Gate rendering on preload completion:
const [ready, setReady] = useState(false); useEffect(() => { preloadAllImages().then(() => setReady(true)); }, []); if (!ready) return <p>Loading images…</p>;
Export Implementation
import { toPng } from "html-to-image"; async function captureSlide(el: HTMLElement, w: number, h: number): Promise<string> { el.style.left = "0px"; el.style.opacity = "1"; el.style.zIndex = "-1"; const opts = { width: w, height: h, pixelRatio: 1, cacheBust: true }; // CRITICAL: Double-call — first warms up fonts/images, second produces clean output await toPng(el, opts); const dataUrl = await toPng(el, opts); el.style.left = "-9999px"; el.style.opacity = ""; el.style.zIndex = ""; return dataUrl; }
Export All (Bulk)
async function exportAll() { if (device === "feature-graphic") { await exportFG(); return; } const size = currentSizes[sizeIdx]; for (let i = 0; i < slides.length; i++) { setExporting(`${i + 1}/${slides.length}`); const el = exportRefs.current[i]; if (!el) continue; const dataUrl = await captureSlide(el, size.w, size.h); const a = document.createElement("a"); a.href = dataUrl; a.download = `${String(i + 1).padStart(2, "0")}-${slides[i].id}-${locale}-${size.w}x${size.h}.png`; a.click(); await new Promise(r => setTimeout(r, 300)); } setExporting(null); }
Key Export Rules
- Double-call trick: First
loads fonts/images lazily. Second produces clean output. Without this, exports are blank.toPng() - On-screen for capture: Temporarily move to
beforeleft: 0
— offscreen elements don't render.toPng - Offscreen container: Use
(notposition: absolute; left: -9999px
) inside afixed
wrapper.overflowX: hidden - 300ms delay between sequential exports — prevents browser throttling.
- Numbered filenames: Zero-padded prefix so files sort correctly:
.01-hero-en-1320x2868.png - Pre-loaded data URIs: Always use
helper. Never use raw file paths in slide components.img() - RGB source images: Ensure source screenshots are RGB (not RGBA). RGBA PNGs can produce transparent/black regions in exports.
Step 7: Final QA Gate
Message Quality
- One idea per slide: if a headline sells two ideas, split it or simplify it
- First slide is strongest: the hero slide must communicate the main benefit immediately
- Readable in one second: if you cannot parse it instantly at arm's length, rewrite it
Visual Quality
- No repeated layouts in sequence: adjacent slides should not feel templated
- Landscape slides feel designed: caption-left + device-right with intentional negative space
- Decorative elements support the story: add energy without covering app UI
- Visual rhythm exists: at least one contrast slide when the set is long enough
Export Quality
- No clipped text or assets after scaling to export size
- Screenshots correctly aligned inside every device frame
- Filenames sort correctly with zero-padded numeric prefixes
- Feature Graphic exports cleanly at 1024×500 (no device frame)
- Theme tokens are applied consistently across all slides in the same preset
- Localized copy still fits after translation, especially on long-word languages
- RTL slides feel designed, not just flipped
Hand-off Behavior
When you present the finished work:
- briefly explain the narrative arc across the slides
- mention any slides that intentionally use contrast or different layout treatment
- call out any assumptions you made about brand tone, copy, or missing assets
Common Mistakes
| Mistake | Fix |
|---|---|
| All slides look the same | Vary device position (center, left, right, two-device, no-device) |
| Landscape slides look broken | Use caption-left + single device-right — never two devices side-by-side |
| Copy is too complex | "One second at arm's length" test |
| Floating elements block the phone | Move off-screen edges or above the device |
| Plain white/black background | Use gradients — even subtle ones add depth |
| Headlines use "and" | Split into two slides or pick one idea |
| Export is blank | Use double-call trick; move element on-screen before capture |
| Phone screens black in export | Images not inlined — use + helper |
| Some slides missing images | Non-deterministic fetch race — same fix as above |
| Export button scrolls off toolbar | Split toolbar: scrollable controls left (), fixed button right () |
| Page has horizontal scroll | Add on the outermost wrapper div |
| Screenshots rejected by App Store | Source PNGs have alpha channel — flatten to RGB (composite onto black) |
| Android tablet orientation ignored | Derive from combo, not just |