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.
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:
Screenshots are advertisements, not documentation. Every screenshot sells one idea. If you're showing UI, you're doing it wrong — you're selling a , an , or killing a .
Before writing ANY code, ask the user all of these. Do not proceed until you have answers:
Based on the user's style direction, brand colors, and app aesthetic, decide:
ar, he, fa, ur), mirror layout intentionally instead of just translating the textIMPORTANT: If the user gives additional instructions at any point during the process, follow them. User instructions always override skill defaults.
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"
# 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
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.
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.
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 page.tsx file. No routing, no extra layouts, no API routes.
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.
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.
// 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>;
}
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:
Get all headlines approved before building layouts. Bad copy ruins good design.
<br />.| 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." |
| 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 |
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:
ar, he, fa, ur), set dir="rtl" on the canvas and mirror asymmetric layouts intentionally.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
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;
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;
const IPAD_SIZES = [
{ label: '13" iPad', w: 2064, h: 2752 },
{ label: '12.9" iPad Pro', w: 2048, h: 2732 },
] as const;
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;
type Device = "iphone" | "android" | "android-7" | "android-10" | "ipad" | "feature-graphic";
type Orientation = "portrait" | "landscape";
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)
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}%``
Each screenshot is designed at full resolution. Two copies exist:
transform: scale() via ResizeObserver to fit a grid cardposition: absolute; left: -9999px at true resolutionCritical: 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" }}>
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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 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>
);
},
};
}
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>
);
}
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 };
})();
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";
All sizing relative to canvas width cW:
| Element | Size | Weight | Line Height |
|---|---|---|---|
| Category label | cW * 0.028 | 600 | default |
| Headline | cW * 0.09 to cW * 0.1 | 700 | 1.0 |
| Hero headline | cW * 0.1 | 700 | 0.92 |
| Feature Graphic name | cW * 0.05 | 800 | 1.1 |
Vary across slides — NEVER use the same layout twice in a row:
Centered device (hero, single-feature):