Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 138 additions & 127 deletions skills/app-store-screenshots/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
name: app-store-screenshots
description: Use when building App Store screenshot pages, generating exportable marketing screenshots for iOS apps, or creating programmatic screenshot generators with Next.js. Triggers on app store, screenshots, marketing assets, html-to-image, phone mockup.
description: Use when building App Store screenshot pages, generating exportable marketing screenshots for iOS apps, or creating programmatic screenshot generators with Next.js. Triggers on app store, screenshots, marketing assets, html2canvas, phone mockup.
---

# App Store Screenshots Generator

## Overview

Build a Next.js page that renders iOS App Store screenshots as **advertisements** (not UI showcases) and exports them via `html-to-image` at Apple's required resolutions. Screenshots are the single most important conversion asset on the App Store.
Build a Next.js page that renders iOS App Store screenshots as **advertisements** (not UI showcases) and exports them via `html2canvas` + `jszip` at Apple's required resolutions. Screenshots are the single most important conversion asset on the App Store.

## Core Principle

Expand All @@ -29,9 +29,8 @@ Before writing ANY code, ask the user all of these. Do not proceed until you hav

### Optional

8. **iPad screenshots** — "Do you also have iPad screenshots? If so, we'll generate iPad App Store screenshots too (recommended for universal apps)."
9. **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."
10. **Additional instructions** — "Any specific requirements, constraints, or preferences?"
8. **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."
9. **Additional instructions** — "Any specific requirements, constraints, or preferences?"

### Derived from answers (do NOT ask — decide yourself)

Expand Down Expand Up @@ -60,37 +59,49 @@ which bun && echo "use bun" || which pnpm && echo "use pnpm" || which yarn && ec
```bash
# With bun:
bunx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
bun add html-to-image
bun add html2canvas jszip

# With pnpm:
pnpx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
pnpm add html-to-image
pnpm add html2canvas jszip

# With yarn:
yarn create next-app . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
yarn add html-to-image
yarn add html2canvas jszip

# With npm:
npx create-next-app@latest . --typescript --tailwind --app --src-dir --no-eslint --import-alias "@/*"
npm install html-to-image
npm install html2canvas jszip
```

### Fix Turbopack Workspace Root

If the project is nested inside another project (e.g., inside a monorepo or app directory), Turbopack may infer the wrong workspace root, causing extremely slow compilation or hangs. **Always** set `turbopack.root` in `next.config.ts`:

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
turbopack: {
root: ".",
},
};

export default nextConfig;
```

### 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. The mockup file is in the same directory as this skill file. No iPad mockup is needed — the iPad frame is CSS-only.
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. The mockup file is in the same directory as this skill file.

### File Structure

```
project/
├── public/
│ ├── mockup.png # iPhone frame (included with skill)
│ ├── mockup.png # Phone frame (included with skill)
│ ├── app-icon.png # User's app icon
│ ├── screenshots/ # iPhone app screenshots
│ │ ├── home.png
│ │ ├── feature-1.png
│ │ └── ...
│ └── screenshots-ipad/ # iPad app screenshots (optional)
│ └── screenshots/ # User's app screenshots
│ ├── home.png
│ ├── feature-1.png
│ └── ...
Expand All @@ -100,8 +111,6 @@ project/
└── package.json
```

**Note:** No iPad mockup PNG is needed — the iPad frame is rendered with CSS (see iPad Mockup Component below).

**The entire generator is a single `page.tsx` file.** No routing, no extra layouts, no API routes.

### Font Setup
Expand Down Expand Up @@ -182,24 +191,22 @@ Get all headlines approved before building layouts. Bad copy ruins good design.

```
page.tsx
├── Constants (IPHONE_W/H, IPAD_W/H, SIZES, design tokens)
├── Phone component (mockup PNG with screen overlay)
├── IPad component (CSS-only frame with screen overlay)
├── Caption component (label + headline, accepts canvasW for scaling)
├── Constants (W, H, SIZES, design tokens from user's brand)
├── Phone component (mockup with screen overlay)
├── Caption component (label + headline)
├── Decorative components (blobs, glows, shapes — based on style direction)
├── iPhoneSlide1..N components (one per slide)
├── iPadSlide1..N components (same designs, adjusted for iPad proportions)
├── IPHONE_SCREENSHOTS / IPAD_SCREENSHOTS arrays (registries)
├── ScreenshotPreview (ResizeObserver scaling + hover export)
└── ScreenshotsPage (grid + device toggle + size dropdown + export logic)
├── Screenshot1..N components (one per slide)
├── SCREENSHOTS array (registry)
├── ScreenshotPreview (ResizeObserver scaling + click-to-export)
├── captureToBlob (html2canvas capture helper)
├── downloadBlob (single file download)
└── ScreenshotsPage (grid + toolbar + zip export via JSZip)
```

### Export Sizes (Apple Required, portrait)

#### iPhone
### Export Sizes (Apple Required — iPhone only, portrait)

```typescript
const IPHONE_SIZES = [
const SIZES = [
{ label: '6.9"', w: 1320, h: 2868 },
{ label: '6.5"', w: 1284, h: 2778 },
{ label: '6.3"', w: 1206, h: 2622 },
Expand All @@ -209,23 +216,6 @@ const IPHONE_SIZES = [

Design at the LARGEST size (1320x2868) and scale down for export.

#### iPad (Optional)

If the user provides iPad screenshots, also generate iPad App Store screenshots:

```typescript
const IPAD_SIZES = [
{ label: '13" iPad', w: 2064, h: 2752 },
{ label: '12.9" iPad Pro', w: 2048, h: 2732 },
] as const;
```

Design iPad slides at 2064x2752 and scale down. iPad screenshots are optional but recommended — they're required for iPad-only apps and improve listing quality for universal apps.

#### Device Toggle

When supporting both devices, add a toggle (iPhone / iPad) in the toolbar next to the size dropdown. The size dropdown should switch between iPhone and iPad sizes based on the selected device. Support a `?device=ipad` URL parameter for headless/automated capture workflows.

### Rendering Strategy

Each screenshot is designed at full resolution (1320x2868px). Two copies exist:
Expand Down Expand Up @@ -272,61 +262,6 @@ function Phone({ src, alt, style, className = "" }: {
}
```

### iPad Mockup Component (CSS-Only)

Unlike the iPhone mockup which uses a pre-measured PNG frame, the iPad uses a **CSS-only frame**. This avoids needing a separate mockup asset and looks clean at any resolution.

**Critical dimension:** The frame aspect ratio must be `770/1000` so the inner screen area (92% width × 94.4% height) matches the 3:4 aspect ratio of iPad screenshots. Using incorrect proportions causes black bars or stretched screenshots.

```tsx
function IPad({ src, alt, style, className = "" }: {
src: string; alt: string; style?: React.CSSProperties; className?: string;
}) {
return (
<div className={`relative ${className}`}
style={{ 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)",
}}>
{/* Front camera dot */}
<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,
}} />
{/* Bezel edge highlight */}
<div style={{
position: "absolute", inset: 0, borderRadius: "5% / 3.6%",
border: "1px solid rgba(255,255,255,0.06)",
pointerEvents: "none", zIndex: 15,
}} />
{/* Screen area */}
<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>
);
}
```

**iPad layout adjustments vs iPhone:**
- Use `width: "65-70%"` for iPad mockups (vs 82-86% for iPhone) — iPad is wider relative to its height
- Two-iPad layouts work the same as two-phone layouts but with adjusted widths
- Caption font sizes should scale from `canvasW` (which is 2064 for iPad vs 1320 for iPhone)
- Same slide designs/copy can be reused — just swap the Phone component for IPad and adjust positioning

### Typography (Resolution-Independent)

All sizing relative to canvas width W:
Expand Down Expand Up @@ -365,41 +300,111 @@ Dark/contrast background with app icon, headline ("And so much more."), and feat

## Step 6: Export

### Why html-to-image, NOT html2canvas
### Why html2canvas + JSZip

**DO NOT use `html-to-image` (or `dom-to-image`).** It serializes the entire DOM into an SVG foreignObject, which hangs or crashes when screenshot images are large (500KB+). This is a consistent, reproducible failure — not an edge case.

**Use `html2canvas`** instead. It renders directly to a Canvas element without SVG serialization. It handles CSS gradients, transforms, border-radius, and opacity reliably. The only things it struggles with are CSS `filter`, `backdrop-filter`, and `mix-blend-mode` — avoid those in slide designs.

`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.
**Use `JSZip` for "Export All".** Browsers block multiple programmatic downloads triggered in sequence. Bundling all screenshots into a single zip file is the only reliable way to export all at once.

### Export Implementation

```typescript
import { toPng } from "html-to-image";

// Before capture: move element on-screen
el.style.left = "0px";
el.style.opacity = "1";
el.style.zIndex = "-1";
import html2canvas from "html2canvas";
import JSZip from "jszip";

// ─── Capture a single element to a Blob ───
async function captureToBlob(
el: HTMLDivElement,
targetW: number,
targetH: number
): Promise<Blob> {
// Move on-screen for capture
el.style.left = "0px";
el.style.top = "0px";
el.style.opacity = "1";
el.style.zIndex = "-1";

// Wait for images + paint
await new Promise((r) => setTimeout(r, 200));

try {
const srcCanvas = await html2canvas(el, {
width: W,
height: H,
scale: 1,
useCORS: true,
allowTaint: true,
backgroundColor: null,
logging: false,
});

// Resize to target if different from design size
let finalCanvas = srcCanvas;
if (targetW !== W || targetH !== H) {
finalCanvas = document.createElement("canvas");
finalCanvas.width = targetW;
finalCanvas.height = targetH;
const ctx = finalCanvas.getContext("2d")!;
ctx.drawImage(srcCanvas, 0, 0, targetW, targetH);
}

return await new Promise<Blob>((res) =>
finalCanvas.toBlob((b) => res(b!), "image/png")
);
} finally {
// Move back off-screen
el.style.left = "-9999px";
el.style.top = "";
el.style.opacity = "";
el.style.zIndex = "";
}
}

const opts = { width: W, height: H, pixelRatio: 1, cacheBust: true };
// ─── Download a blob ───
function downloadBlob(blob: Blob, filename: string) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}

// CRITICAL: Double-call trick — first warms up fonts/images, second produces clean output
await toPng(el, opts);
const dataUrl = await toPng(el, opts);
// ─── Export All: capture all slides into a zip ───
async function exportAll(refs, size) {
const zip = new JSZip();
for (let i = 0; i < SCREENSHOTS.length; i++) {
const ss = SCREENSHOTS[i];
const el = refs.get(ss.id);
if (!el) continue;
const blob = await captureToBlob(el, size.w, size.h);
const idx = String(i + 1).padStart(2, "0");
zip.file(`${idx}-${ss.id}-${size.w}x${size.h}.png`, blob);
}
const zipBlob = await zip.generateAsync({ type: "blob" });
downloadBlob(zipBlob, `screenshots-${size.w}x${size.h}.zip`);
}

// After capture: move back off-screen
el.style.left = "-9999px";
el.style.opacity = "";
el.style.zIndex = "";
// ─── Export Single: download one PNG ───
async function exportSingle(el, id, index, size) {
const blob = await captureToBlob(el, size.w, size.h);
const idx = String(index + 1).padStart(2, "0");
downloadBlob(blob, `${idx}-${id}-${size.w}x${size.h}.png`);
}
```

### Key Rules

- **Double-call trick**: First `toPng()` loads fonts/images lazily. Second produces clean output. Without this, exports are blank.
- **On-screen for capture**: Temporarily move to `left: 0` before calling `toPng`.
- **Offscreen container**: Use `position: absolute; left: -9999px` (not `fixed`).
- **Resizing**: Load data URL into Image, draw onto canvas at target size.
- 300ms delay between sequential exports.
- Set `fontFamily` on the offscreen container.
- **Numbered filenames**: Prefix exports with zero-padded index so they sort correctly: `01-hero-1320x2868.png`, `02-freshness-1320x2868.png`, etc. Use `String(index + 1).padStart(2, "0")`.
- **NEVER use html-to-image / dom-to-image.** They hang on large images.
- **On-screen for capture**: Move to `left: 0; top: 0` before `html2canvas`. Move back to `left: -9999px` after.
- **Offscreen container**: Use `position: absolute; left: -9999px` (not `display: none` or `visibility: hidden` — html2canvas needs the element in the layout).
- **200ms settle time**: Wait after moving on-screen so images paint before capture.
- **Zip for bulk export**: Always use JSZip for "Export All" — individual downloads get blocked by browsers.
- **Numbered filenames**: Prefix with zero-padded index: `01-hero-1320x2868.png`.
- **Avoid CSS filters in slides**: `filter`, `backdrop-filter`, and `mix-blend-mode` don't render in html2canvas. Use `opacity`, `background: radial-gradient(...)`, and `box-shadow` instead for visual effects.

## Common Mistakes

Expand All @@ -414,4 +419,10 @@ el.style.zIndex = "";
| Too simple/empty | Add larger decorative elements, floating items at edges |
| Headlines use "and" | Split into two slides or pick one idea |
| No visual contrast across slides | Mix light and dark backgrounds |
| Export is blank | Use double-call trick; move element on-screen before capture |
| Export hangs or is blank | Use html2canvas (NOT html-to-image). Move element on-screen before capture. Wait 200ms for paint. |
| "Export All" only downloads first file | Browsers block multiple downloads. Use JSZip to bundle into a single zip file. |
| Using html-to-image / dom-to-image | **NEVER.** These hang on large images (>500KB). Always use html2canvas. |
| Page won't load / stuck compiling | Set `turbopack.root: "."` in next.config.ts. Kill zombie node processes on the port. Clear `.next` cache. |
| Zombie server blocking port | Run `lsof -ti:PORT \| xargs kill -9` before starting new server. |
| CSS effects missing in export | html2canvas doesn't support `filter`, `backdrop-filter`, `mix-blend-mode`. Use `opacity`, `radial-gradient`, `box-shadow` instead. |
| Image files have alpha transparency, which app store will reject | Make sure to strip alpha channels from files when generating |