app-store-screenshots

Installation
SKILL.md

App Store Screenshots Generator

Overview

Build a Next.js page that renders App Store screenshots for iPhone and iPad as advertisements (not UI showcases) and exports them via html-to-image at Apple's required resolutions. Uses pre-framed device screenshots from ascelerate screenshot frame (Apple device bezels with pixel-perfect accuracy, including Dynamic Island and rounded corners). Screenshots are the single most important conversion asset on the App Store.

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, gather all required information. Ask questions one at a time — wait for the user's answer before asking the next question. Dumping all questions at once is overwhelming. If the user volunteers information about upcoming questions, acknowledge it and skip those when you get to them.

Required

  1. Framed screenshots — "Where are your framed device screenshots? (from ascelerate screenshot frame — typically screenshots/framed/)"
  2. App icon — "Where is your app icon PNG?"
  3. Brand colors — "What are your brand colors? (accent color, text color, background preference)"
  4. Font — "What font does your app use? (or what font do you want for the screenshots?)"
  5. Feature list — "List your app's features in priority order. What's the #1 thing your app does?"
  6. Number of slides — "How many screenshots do you want? (Apple allows up to 10)"
  7. 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 (ask after required questions are answered)

  1. Localization — "Do you want screenshots in multiple languages? If so, which languages? (e.g. English, German, French). You'll get a language switcher in the preview to toggle between them."
  2. 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."
  3. 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

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

Requirements

  • Node.js 18+
  • Framed screenshots — generated by ascelerate screenshot frame (see ascelerate). The user should have framed device screenshots ready before starting.

Project Location

Default location is app-store-screenshots/ at the root of the user's working directory. Ask the user: "I'll create the project in app-store-screenshots/. Want me to use a different folder or a temporary location instead?" If the directory already exists with a Next.js project, reuse it.

Git tracking: After creating the project, check if the working directory is a git repo (git rev-parse --is-inside-work-tree). If so, suggest adding the project folder (or at minimum its node_modules/) to .gitignore.

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)

Create the project inside app-store-screenshots/:

mkdir -p app-store-screenshots && cd app-store-screenshots

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

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

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

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

File Structure

app-store-screenshots/
├── public/
│   ├── app-icon.png            # User's app icon
│   └── framed/                 # Framed screenshots from ascelerate
│       ├── en-US/              # Per-locale directories (matches ascelerate output)
│       │   ├── iPhone 17 Pro Max-01-home.png
│       │   ├── iPhone 17 Pro Max-02-settings.png
│       │   └── iPad Pro 13-inch (M5)-01-home.png
│       └── de-DE/
│           └── ...
├── src/app/
│   ├── copy.json               # Localized copy (editable without touching code)
│   ├── layout.tsx              # Font setup
│   └── page.tsx                # The screenshot generator (single file)
└── package.json

Copy or symlink the framed screenshots from ascelerate's output (screenshots/framed/) into public/framed/. The directory structure matches ascelerate's output: {locale}/{device}-{name}.png.

If the app UI is the same across languages (only marketing text changes), copy only one locale — the Device component's onError fallback handles missing locales.

The generator is page.tsx + copy.json. No routing, no extra layouts, no API routes. All text lives in copy.json so the user can update copy without editing components.

Font Setup

// src/app/layout.tsx
import { YourFont } from "next/font/google"; // Use whatever font the user specified
const font = YourFont({ subsets: ["latin"] });

export default function Layout({ children }: { children: React.ReactNode }) {
  return <html><body className={font.className}>{children}</body></html>;
}

Step 2.5: Set Up Framed Screenshots

Before building the page, copy the framed device screenshots into the project. These are pre-generated by ascelerate screenshot frame, which composites raw screenshots into Apple device bezels with pixel-perfect accuracy (Dynamic Island, rounded corners, bezel fully intact).

Copy Framed Screenshots

Based on the user's answer to question #8, copy or symlink the framed output:

# Copy framed screenshots from ascelerate's output into the Next.js public directory
cp -r /path/to/screenshots/framed/* public/framed/

The framed directory structure from ascelerate looks like:

screenshots/framed/
├── en-US/
│   ├── iPhone 17 Pro Max-01-home.png
│   ├── iPhone 17 Pro Max-02-settings.png
│   └── iPad Pro 13-inch (M5)-01-home.png
└── de-DE/
    └── ...

If the app UI doesn't change across languages (only marketing text differs), copy only one locale — the Device component's onError fallback handles missing locales.

Important Notes

  • Framing is configured per-device in ascelerate/screenshot.yml via frameDevice and deviceBezel — see ascelerate docs
  • The framed images already contain the device bezel, Dynamic Island, and rounded corners — the Next.js page just renders them as <img> tags
  • If the user hasn't framed their screenshots yet, they should run ascelerate screenshot frame first

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.

Step 4: Write Copy FIRST

Get all headlines approved before building layouts. Bad copy ruins good design.

The Iron Rules

  1. One idea per headline. Never join two things with "and."
  2. Short, common words. 1-2 syllables. No jargon unless it's domain-specific.
  3. 3-5 words per line. Must be readable at thumbnail size in the App Store.
  4. 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"
  • Compound clauses: "Save and customize X for every Y you own"
  • Vague aspirational: "Every item, tracked"
  • Marketing buzzwords: "AI-powered tips" (unless it's actually AI)

Copy Process

  1. Write 3 options per slide using the three approaches
  2. Read each at arm's length — if you can't parse it in 1 second, it's too complex
  3. Check: does each line have 3-5 words? If not, adjust line breaks
  4. Present options to the user with reasoning for each
  5. If localized: translate approved headlines into all requested languages. Respect the tone/formality of each language. Line breaks may need adjusting per language — German and French text is often longer than English.

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 (IPHONE_W, IPHONE_H, IPAD_W, IPAD_H, design tokens)
├── LOCALES (code, label, asc locale code for App Store Connect)
├── ASC_DEVICE (maps "iphone" → "APP_IPHONE_67", "ipad" → "APP_IPAD_PRO_3GEN_129")
├── COPY (imported from copy.json — localized strings per language, keyed by slide name)
├── Device component (renders a pre-framed device image — just an <img>)
├── Caption component (label + headline)
├── Decorative components (blobs, glows, shapes — based on style direction)
├── Screenshot1..N components (one per slide, with iPhone and/or iPad variants)
├── SCREENSHOTS array (registry, with device type tag)
├── ScreenshotPreview (ResizeObserver scaling + hover export)
└── ScreenshotsPage (grid + toolbar with language switcher + device filter + Export/Export All Languages)

Export Sizes (Largest Only — Apple auto-scales to smaller displays)

App Store Connect accepts the largest screenshot size per device and automatically scales it for smaller displays. Only generate:

// iPhone 6.9" — covers all iPhone sizes
const IPHONE_W = 1320;
const IPHONE_H = 2868;

// iPad 13" — covers all iPad sizes
const IPAD_W = 2064;
const IPAD_H = 2752;

Rendering Strategy

Each screenshot is designed at full export resolution. Two copies exist:

  1. Preview: CSS transform: scale() via ResizeObserver to fit a grid card
  2. Export: Offscreen at position: absolute; left: -9999px at true resolution

If both iPhone and iPad are requested:

  • Separate sections: Render iPhone and iPad in distinct sections with device headers (<h2>iPhone</h2>, <h2>iPad</h2>), each with its own grid. Do NOT mix them in a single grid.
  • Device filter: The toolbar includes an All / iPhone / iPad toggle that shows/hides sections.
  • Each section filters SCREENSHOTS.filter(s => s.device === "iphone") etc.

Localization

If the user wants multiple languages, store all copy in a separate copy.json file so the user can edit text without touching code:

src/app/copy.json
{
  "en": {
    "iphone-hero": { "headline": "Spam texts,<br/>silenced.", "pills": ["Smart", "Private"] },
    "iphone-privacy": { "label": "On-device AI", "headline": "Completely<br/>private.", "sub": "..." }
  },
  "de": {
    "iphone-hero": { "headline": "Spam-SMS,<br/>verstummt.", "pills": ["Intelligent", "Datenschutz"] },
    "iphone-privacy": { "label": "KI auf dem Gerät", "headline": "Vollständig<br/>privat.", "sub": "..." }
  }
}

Import and type-cast it in page.tsx:

import COPY_JSON from "./copy.json";
const COPY = COPY_JSON as Record<Locale, Record<string, SlideCopy>>;

Each slide component receives the current locale and looks up its copy from COPY[locale][slideName].

Localized device images: If app screenshots differ per language (localized UI), ascelerate's framed output is already organized by locale: public/framed/en-US/, public/framed/de-DE/, etc. Slide components use the current locale in the path: src={/framed/${ascLocale}/${deviceName}-${screenshotName}.png}. One locale is the required default — all others are optional and fall back via onError on the Device component.

ScreenshotPreview Component

Each slide is previewed using CSS scale-down via ResizeObserver. Clicking a preview exports that single slide:

function ScreenshotPreview({ def, index, locale, w, h }: {
  def: ScreenshotDef; index: number; locale: Locale; w: number; h: number;
}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState(0.2);
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) setScale(entry.contentRect.width / w);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [w]);
  const Comp = def.component;
  return (
    <div ref={containerRef}
      style={{ position: "relative", overflow: "hidden", borderRadius: 12,
        border: "1px solid #e5e7eb", aspectRatio: `${w}/${h}`,
        cursor: "pointer", background: "#fafafa" }}>
      <div style={{ transformOrigin: "top left", transform: `scale(${scale})`, width: w, height: h }}>
        <Comp w={w} h={h} locale={locale} />
      </div>
      <div style={{ position: "absolute", bottom: 8, left: 8, background: "rgba(0,0,0,0.6)",
        color: "#fff", fontSize: 11, fontWeight: 600, padding: "3px 8px", borderRadius: 6 }}>
        #{index + 1}{def.name}
      </div>
    </div>
  );
}

Toolbar & Page Layout

The main page has a toolbar with a language switcher, device filter (if both iPhone and iPad), and export buttons. The grid shows preview cards. Offscreen containers hold full-resolution slides for export:

export default function ScreenshotsPage() {
  const exportRefs = useRef<Map<number, HTMLDivElement>>(new Map());
  const [exporting, setExporting] = useState(false);
  const [locale, setLocale] = useState<Locale>("en");
  // If both iPhone and iPad: const [deviceFilter, setDeviceFilter] = useState<"all"|"iphone"|"ipad">("all");

  return (
    <div style={{ padding: 32, maxWidth: 1400, margin: "0 auto" }}>
      {/* Toolbar */}
      <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 32, flexWrap: "wrap" }}>
        <h1 style={{ fontSize: 24, fontWeight: 700, color: "#111", margin: 0 }}>App Screenshots</h1>
        <div style={{ flex: 1 }} />

        {/* Language Switcher */}
        <select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}
          style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #e5e7eb",
            background: "#fff", color: "#333", fontWeight: 600, fontSize: 14, cursor: "pointer" }}>
          {LOCALES.map((l) => (
            <option key={l.code} value={l.code}>{l.label} ({l.code})</option>
          ))}
        </select>

        {/* Device filter (only if both iPhone and iPad) */}
        {/* <select value={deviceFilter} onChange={...}> All / iPhone / iPad </select> */}

        <button onClick={handleExportAll} disabled={exporting}
          style={{ padding: "8px 20px", borderRadius: 8, border: "none",
            background: exporting ? "#999" : BRAND_COLOR, color: "#fff",
            fontWeight: 600, fontSize: 14, cursor: exporting ? "not-allowed" : "pointer" }}>
          {exporting ? "Exporting..." : `Export (${locale.toUpperCase()})`}
        </button>
        <button onClick={handleExportAllLanguages} disabled={exporting}
          style={{ padding: "8px 20px", borderRadius: 8, border: "none",
            background: exporting ? "#999" : BRAND_DARK, color: "#fff",
            fontWeight: 600, fontSize: 14, cursor: exporting ? "not-allowed" : "pointer" }}>
          {exporting ? "Exporting..." : "Export All Languages"}
        </button>
      </div>

      {/* Screenshots Grid */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 20 }}>
        {SCREENSHOTS.map((def, i) => (
          <div key={def.name} onClick={() => handleExportSingle(i)}>
            <ScreenshotPreview def={def} index={i} locale={locale} w={IPHONE_W} h={IPHONE_H} />
          </div>
        ))}
      </div>

      {/* Offscreen Export Containers */}
      {SCREENSHOTS.map((def, i) => {
        const Comp = def.component;
        return (
          <div key={`export-${i}`}
            ref={(el) => { if (el?.firstElementChild) exportRefs.current.set(i, el.firstElementChild as HTMLDivElement); }}
            style={{ position: "absolute", left: -9999, fontFamily: '"SF Pro Display", "SF Pro", system-ui, sans-serif' }}>
            <Comp w={IPHONE_W} h={IPHONE_H} locale={locale} />
          </div>
        );
      })}
    </div>
  );
}

Export Handlers

Three export functions: single slide, current language, and all languages:

async function captureScreenshot(el: HTMLElement, w: number, h: number): Promise<string> {
  const wrapper = el.parentElement!;
  wrapper.style.left = "0px";
  wrapper.style.opacity = "1";
  wrapper.style.zIndex = "-1";
  const opts = { width: w, height: h, pixelRatio: 1, cacheBust: true };
  await toPng(el, opts); // warm-up
  const dataUrl = await toPng(el, opts); // actual capture
  wrapper.style.left = "-9999px";
  wrapper.style.opacity = "";
  wrapper.style.zIndex = "";
  return dataUrl;
}

// Export current language as zip
const handleExportAll = useCallback(async () => {
  setExporting(true);
  const zip = new JSZip();
  const ascLocale = LOCALES.find((l) => l.code === locale)!.asc;
  for (let i = 0; i < SCREENSHOTS.length; i++) {
    const def = SCREENSHOTS[i];
    const el = exportRefs.current.get(i);
    if (!el) continue;
    const prefix = String(i + 1).padStart(2, "0");
    const dataUrl = await captureScreenshot(el, IPHONE_W, IPHONE_H);
    zip.file(`${ascLocale}/${ASC_DEVICE}/${prefix}_${def.name}.png`, dataUrlToBlob(dataUrl));
    await new Promise((r) => setTimeout(r, 300));
  }
  const blob = await zip.generateAsync({ type: "blob" });
  const url = URL.createObjectURL(blob);
  downloadDataUrl(url, `screenshots-${ascLocale}.zip`);
  URL.revokeObjectURL(url);
  setExporting(false);
}, [locale]);

// Export ALL languages as single zip
const handleExportAllLanguages = useCallback(async () => {
  setExporting(true);
  const zip = new JSZip();
  const originalLocale = locale;
  for (const lang of LOCALES) {
    setLocale(lang.code);
    await new Promise((r) => setTimeout(r, 500)); // wait for re-render
    for (let i = 0; i < SCREENSHOTS.length; i++) {
      const def = SCREENSHOTS[i];
      const el = exportRefs.current.get(i);
      if (!el) continue;
      const prefix = String(i + 1).padStart(2, "0");
      const dataUrl = await captureScreenshot(el, IPHONE_W, IPHONE_H);
      zip.file(`${lang.asc}/${ASC_DEVICE}/${prefix}_${def.name}.png`, dataUrlToBlob(dataUrl));
      await new Promise((r) => setTimeout(r, 300));
    }
  }
  setLocale(originalLocale);
  const blob = await zip.generateAsync({ type: "blob" });
  const url = URL.createObjectURL(blob);
  downloadDataUrl(url, "appname-screenshots.zip");
  URL.revokeObjectURL(url);
  setExporting(false);
}, [locale]);

// Export single slide as PNG
const handleExportSingle = useCallback(async (idx: number) => {
  const def = SCREENSHOTS[idx];
  const el = exportRefs.current.get(idx);
  if (!el || !def) return;
  setExporting(true);
  const prefix = String(idx + 1).padStart(2, "0");
  const filename = `${prefix}-iphone-${def.name}-${locale}-${IPHONE_W}x${IPHONE_H}.png`;
  const dataUrl = await captureScreenshot(el, IPHONE_W, IPHONE_H);
  downloadDataUrl(dataUrl, filename);
  setExporting(false);
}, [locale]);

Helper functions:

function downloadDataUrl(dataUrl: string, filename: string) {
  const link = document.createElement("a");
  link.download = filename;
  link.href = dataUrl;
  link.click();
}

function dataUrlToBlob(dataUrl: string): Uint8Array {
  const base64 = dataUrl.split(",")[1];
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

The "Export All Languages" zip structure is compatible with ascelerate apps media upload <app> --folder screenshots.zip --replace for direct upload to App Store Connect.

Device Component

Since ascelerate screenshot frame produces complete device-framed images (bezel + screenshot composited together), the device component is just an <img> tag — no CSS clipping, no screen offset calculations, no border-radius hacks:

function Device({ src, alt, style }: {
  src: string; alt: string; style?: React.CSSProperties;
}) {
  return (
    <img
      src={src}
      alt={alt}
      style={style}
      draggable={false}
      onError={(e) => {
        // Fall back to English if localized image is missing
        const img = e.currentTarget;
        const enSrc = img.src.replace(/\/(?!en\/)[a-z]{2}\//, "/en/");
        if (img.src !== enSrc) img.src = enSrc;
      }}
    />
  );
}

The framed images already contain the device bezel with the Dynamic Island, rounded corners, and screen content composited pixel-perfectly. The transparent areas around the device allow it to sit naturally on any background. The onError fallback ensures missing locale-specific images gracefully degrade to English.

Typography (Resolution-Independent)

All sizing relative to canvas width W (use IPHONE_W or IPAD_W depending on device):

Element Size Weight Line Height
Category label W * 0.028 600 (semibold) default
Headline W * 0.09 to W * 0.1 700 (bold) 1.0
Hero headline W * 0.1 700 (bold) 0.92

Device Placement Patterns

Vary across slides — NEVER use the same layout twice in a row:

Centered device (hero, single-feature):

bottom: 0, width: "82-86%", translateX(-50%) translateY(12-14%)

Two devices layered (comparison):

Back: left: "-8%", width: "65%", rotate(-4deg), opacity: 0.55
Front: right: "-4%", width: "82%", translateY(10%)

Device + floating elements (only if user provided component PNGs):

Cards should NOT block the device's main content.
Position at edges, slight rotation (2-5deg), drop shadows.
If distracting, push partially off-screen or make smaller.

iPad-Specific Layout Notes

iPad screenshots have a landscape-ish aspect ratio (2064×2752, closer to 3:4 vs iPhone's ~1:2.2). This affects layouts:

  • The device image is wider relative to the canvas — use narrower widths (70-78% instead of 82-86%) to avoid crowding
  • Captions may need to sit above the device rather than beside it, since the wider device leaves less horizontal space
  • Two-device layouts work best with smaller scaling and more overlap
  • iPad's larger visible screen area means the app UI is more readable — lean into showing the content

"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.

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 most CSS faithfully.

IMPORTANT: Use filter: drop-shadow() instead of box-shadow. While html-to-image handles most CSS well, box-shadow renders with visible boxy artifacts in exports. Always use CSS filter: drop-shadow() for shadows on devices, floating elements, and cards. Note that drop-shadow blur radius is roughly half of box-shadow blur for a similar visual effect:

/* BAD — renders boxy/corrupted in exports */
box-shadow: 0 16px 50px rgba(0,0,0,0.35);

/* GOOD — renders correctly in exports */
filter: drop-shadow(0 16px 25px rgba(0,0,0,0.35));

Export Implementation

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

// CRITICAL: el must be the slide component's root div (position: relative),
// NOT the offscreen wrapper. Use ref on wrapper, then ref.firstElementChild.
// toPng must capture the element that owns the positioned children.
const wrapper = el.parentElement!;

// Before capture: move wrapper on-screen
wrapper.style.left = "0px";
wrapper.style.opacity = "1";
wrapper.style.zIndex = "-1";

const opts = { width: W, height: H, pixelRatio: 1, cacheBust: true };

// CRITICAL: Double-call trick — first warms up fonts/images, second produces clean output
await toPng(el, opts);
const dataUrl = await toPng(el, opts);

// After capture: move wrapper back off-screen
wrapper.style.left = "-9999px";
wrapper.style.opacity = "";
wrapper.style.zIndex = "";

Use W = IPHONE_W, H = IPHONE_H for iPhone slides and W = IPAD_W, H = IPAD_H for iPad slides.

Offscreen container setup: The wrapper div is position: absolute; left: -9999px with fontFamily set. The ref should resolve to the slide's root div inside it (via firstElementChild), not the wrapper itself:

ref={(el) => { if (el?.firstElementChild) exportRefs.current.set(i, el.firstElementChild as HTMLDivElement); }}

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 wrapper: Use position: absolute; left: -9999px (not fixed). Do NOT set width/height on the wrapper — let the slide component define its own dimensions.
  • No resizing needed: Each slide is already at the exact export resolution. No canvas scaling step.
  • Zip export (ascelerate compatible): Two export buttons. "Export ({LOCALE})" exports the current language. "Export All Languages" loops through every locale (sets state, waits for re-render, captures). Both create a zip compatible with ascelerate. Structure:
    en-US/APP_IPHONE_67/01_hero.png
    en-US/APP_IPAD_PRO_3GEN_129/01_hero.png
    de-DE/APP_IPHONE_67/01_hero.png
    
    Device type mapping: iphone → APP_IPHONE_67, ipad → APP_IPAD_PRO_3GEN_129. Each locale entry needs an asc field for the App Store Connect locale code (e.g. { code: "en", label: "English", asc: "en-US" }). Files are ordered alphabetically so use zero-padded prefixes: 01_name.png.
  • Single-click export: Downloads individual PNGs with device in filename: 01-iphone-hero-en-1320x2868.png.
  • 300ms delay between sequential exports.
  • Set fontFamily on the offscreen container.
  • Use String(index + 1).padStart(2, "0") for numbering.

Common Mistakes

Mistake Fix
All slides look the same Vary device position (center, left, right, two-device, no-device)
Decorative elements invisible Increase size and opacity — better too visible than invisible
Copy is too complex "One second at arm's length" test
Floating elements block the device Move off-screen edges or above the device
Plain white/black background Use gradients — even subtle ones add depth
Too cluttered Remove floating elements, simplify to device + caption
Too simple/empty Add larger decorative elements, floating items at edges
Device frame touches canvas edge Always keep padding between the device and the canvas sides — touching the edge looks cheap and cramped
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 has empty top, content at bottom toPng must target the slide's root div (has position: relative), not the offscreen wrapper
Screenshots not framed Run ascelerate screenshot frame first, then copy screenshots/framed/ to public/framed/
Shadows look boxy/corrupted in export Use filter: drop-shadow() instead of box-shadowhtml-to-image doesn't render box-shadow correctly
Framed image looks wrong Check bezel configuration in ascelerate/screenshot.yml — bezel must match the capture device
Related skills
Installs
10
GitHub Stars
140
First Seen
Mar 20, 2026