A tiny, project-agnostic App Store screenshot engine for SwiftUI apps on macOS and iOS.
ShotKit renders your real views into marketing screenshots at exact App Store
pixel sizes. It captures the live view hierarchy from a real window, so
native controls that ImageRenderer replaces with a placeholder (segmented
Pickers, linear ProgressViews, Sliders, Toggles, and more) come out
looking exactly like the running app.
ImageRenderer rasterizes SwiftUI's own drawing, but AppKit- and UIKit-backed
controls aren't part of that drawing. A segmented picker, a linear progress bar,
a slider, or a toggle each comes out as a "no-entry" placeholder, and a
ScrollView's content below the fold can be missing. ShotKit instead hosts the
view in a real window and snapshots the live hierarchy (cacheDisplay on macOS,
drawHierarchy on iOS), so what you capture is what the user sees, at the
screen's backing scale (2x on Retina gives a 2880x1800 image from a 1440x900
canvas).
The image above is Examples/CafeApp's ControlsPanel, captured both ways.
Regenerate it with swift run ComparisonTool Examples/CafeApp/Screenshots.
- True-to-app capture of real controls and charts, not just SwiftUI drawing.
- Auto-fit composition.
ShotCardwraps your view in an optional headline + marketing card and scales the whole thing to fit the canvas, so a compact popover stays large while a tall settings/scroll window shrinks just enough to be captured whole. Nothing clips, with no per-screenshot tuning. - Optional captions. Pass
title: niltoShotCardto capture the framed shot without any headline/subtitle text, just the view. - iPhone device frames. Wrap iOS screens in
DeviceFramefor a real notch, status bar, and bezel, so shots read as phone screenshots, not flat images. - Customizable background.
ShotCarddefaults to a dark gradient, but takes anyViewas itsbackground— a brand color, a different gradient, an image — set per scene. - Exact App Store sizes via
ScreenshotSpec/AppStoreSize(macOS and iOS presets). - Batch export to a folder as
<name>.png.
- macOS 13+ or iOS 16+
- A live UI session. Capture uses a real (briefly on-screen) window, so ShotKit
needs a window server / active window scene — run it from your app, or from a
small executable that brings up
NSApplication(see the example'sCafeExportTool). A plain script with no app context won't render.
Swift Package Manager. Add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/birangdev/ShotKit.git", from: "1.0.0")
]and add "ShotKit" to your target's dependencies. In Xcode:
File > Add Package Dependencies… and enter
https://github.com/birangdev/ShotKit.git.
The product is built as a static library, so if your only call sites are
compiled out (for example behind #if DEBUG), the linker drops ShotKit from
your Release binary.
Describe each screenshot as a ScreenshotScene: give it a spec (name + size)
and a makeContent() that returns the view to capture. ShotCard gives you a
ready-made marketing layout, but you can return any view.
import ShotKit
import SwiftUI
struct MenuScene: ScreenshotScene {
var spec: ScreenshotSpec { ScreenshotSpec("01-overview") }
@MainActor
func makeContent() -> AnyView {
AnyView(
ShotCard(
"Your limits, at a glance",
subtitle: "Live usage, right in your menu bar",
accent: .green
) {
// Your real view, at its natural size.
MenuBarView(model: .demo)
.fixedSize()
.background(Color(nsColor: .windowBackgroundColor))
}
)
}
}Then export a batch to a folder:
@MainActor
func exportScreenshots(to folder: URL) {
let scenes: [ScreenshotScene] = [MenuScene(), /* ... */]
let urls = ShotKit.export(scenes, to: folder)
print("Wrote \(urls.count) screenshots to \(folder.path)")
}ShotKit.export creates the folder if needed and writes one PNG per scene named
after its spec.name. For a single image, use ShotKit.capturePNG(_:) and write
the Data yourself.
ShotCard's title and subtitle are optional. Omit them to capture just the
framed view (still on the gradient background, still auto-fit) with no text:
// Marketing shot with a headline:
ShotCard("Your limits, at a glance", subtitle: "Right in your menu bar") { view }
// Same framed shot, no caption text:
ShotCard { view }Thread a flag through your scenes to switch between the two for a whole batch.
On macOS you usually capture a real app window or menu-bar popover directly — no
device frame needed. The default ShotCard gives the content a rounded "window"
look with a border and shadow, and its auto-fit scales it to the canvas:
ShotCard("Everything at a glance", subtitle: "Right in your menu bar") {
MenuBarView(model: .demo) // your real SwiftUI view
.fixedSize() // natural window size
.background(Color(nsColor: .windowBackgroundColor))
}Keep framed: true (the default) on macOS. DeviceFrame is for iOS screens (see
below); you don't use it for Mac windows.
For iOS screenshots, wrap the screen in DeviceFrame so it reads as a real
phone, and pass framed: false to ShotCard so it doesn't add a Mac-window
border around the device:
ShotCard("Order in seconds", subtitle: "Your usual, one tap away", framed: false) {
DeviceFrame {
MyScreen().frame(width: 393, height: 852)
}
}DeviceFrame draws the bezel, a notch (or .dynamicIsland), a status bar, and
side buttons from pure SwiftUI shapes. Size the screen content yourself; content
taller than the device is clipped to what fits. Options:
DeviceFrame(
cutout: .notch, // .dynamicIsland / .none
showsStatusBar: true,
statusBarTime: "9:41",
lightStatusBar: true, // white glyphs for dark content, false for light
showsSideButtons: true
) { screen }ShotCard defaults to a dark gradient, but takes any View as its
background — set it per scene to brand a batch differently, or to match a
scene's accent:
ShotCard("Fuel up", subtitle: "Track every cup", background: {
LinearGradient(colors: [.brown, .black], startPoint: .top, endPoint: .bottom)
}) {
view
}Pass a plain Color, an Image, or any composed view — it fills the whole
canvas behind the caption and framed content, same as the default gradient.
- Return the view at its natural size (
.fixedSize()), or pin only the axis you want fixed.ShotCardscales the result to fit, so aScrollViewor groupedFormgiven.fixedSize(vertical: true)expands to its full content height and gets captured whole rather than clipped to a scrolling viewport. - Drive the view from a demo/fixture model so it renders a known state and does
not depend on live data. Because capture runs a real run loop, the view's
onAppearwill fire, so make sure your model does not overwrite the fixture.
Because capture needs a window server, trigger it from inside your app. A common pattern is an environment-variable hook that exports and quits, so screenshots can be regenerated from the command line:
// In your AppDelegate / applicationDidFinishLaunching:
if ProcessInfo.processInfo.environment["EXPORT_SHOTS"] != nil {
let folder = /* a folder your app can write to */
ShotKit.export(allScenes, to: folder)
NSApp.terminate(nil)
}EXPORT_SHOTS=1 path/to/YourApp.app/Contents/MacOS/YourAppShotKit was pulled out of
StackGauge, a macOS
menu-bar app, after ImageRenderer kept dropping the gauge, toggles, and charts
from its App Store screenshots. Every image on the StackGauge listing is a
ShotKit capture of the real app views driven by fixture data. Two of them:
| Menu popover | History window |
|---|---|
![]() |
![]() |
The gauge, the toggle, the progress bar, and the chart are exactly the controls a rendered screenshot loses.
Examples/CafeApp is a self-contained example that captures four screens of
CafeApp, a coffee-ordering app, in the
order the app navigates them: Home → coffee styles → size → extras. It stands in for the
app's RealmSwift models and custom artwork with fixture data (CoffeesMock.json)
and SF Symbols, so it builds on its own with no third-party dependencies. It's
the file to copy from to see how to drive ShotKit: each screen is a full iPhone
DeviceFrame inside a ShotCard, over a custom mocha background.
| Home | Styles | Size | Extras |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Regenerate the screenshots:
swift run CafeExportTool Examples/CafeApp/Screenshotsswift run ComparisonTool Examples/CafeApp/Screenshots regenerates the
ImageRenderer vs ShotKit image near the top of this README from the same
example module.
The example targets aren't part of the ShotKit library product, so they're
never pulled into an app that depends on ShotKit — they only build when you
build this repo. See Examples/CafeApp/README.md for details.
| Type | Purpose |
|---|---|
ScreenshotSpec |
A screenshot's output name, point size, and scale. |
AppStoreSize |
Standard canvas sizes. macOS: .macPoints (1440x900 @2), .macPointsAlt (1280x800 @2). iOS: .iPhone67 (@3 -> 1290x2796), .iPhone65 (@3 -> 1242x2688), .iPad13 (@2 -> 2048x2732). |
ScreenshotScene |
Protocol: a spec plus @MainActor func makeContent() -> AnyView. |
ShotKit.capturePNG(_:) |
Captures one scene to PNG Data. |
ShotKit.export(_:to:) |
Captures many scenes and writes PNGs into a folder. |
ShotCard |
Marketing card: customizable background (dark gradient by default), optional headline/subtitle, auto-fit. framed: false drops the Mac-window border for content with its own shape. |
DeviceFrame |
iPhone mockup (notch or Dynamic Island, status bar, side buttons) wrapping your screen content. Pair with ShotCard(framed: false). |
ScaledContent / ScaledLayout |
Scale a view while reserving its scaled size in layout (used by ShotCard; reusable). |
ScreenshotSpec("05-history") // 1440x900 @2x (mac)
ScreenshotSpec("wide", pointSize: AppStoreSize.macPointsAlt) // 1280x800 @2x (mac)
ScreenshotSpec("phone", pointSize: AppStoreSize.iPhone67, scale: 3) // 1290x2796 (iOS)pixelSize = pointSize * scale. Pick a pointSize and scale whose product is
a size the App Store accepts (for example 1440x900 @2x = 2880x1800).
makeContent()is framed tospec.pointSizeand hosted in a real window in dark appearance (NSWindowon macOS,UIWindowon iOS).- The window is briefly shown so SwiftUI, native controls, and Charts lay out
and draw, then the hierarchy is snapshotted (
cacheDisplayon macOS,drawHierarchyon iOS) atspec.scale. ShotCardcomposes the marketing card and usesViewThatFitsover a set of candidate scales. Each candidate reserves its true scaled size viaScaledLayout, so the largest one that actually fits is chosen and nothing overflows the canvas.
ShotKit 1.0 covers the workflow it was built for. I'd rather grow it from real use than guess, so if one of these would help you, open an issue and say how you'd use it:
- Localization. Capture the same scenes across languages in one run.
- More device frames. iPad, landscape, older iPhone sizes.
- More App Store size presets. Especially the newer iPad requirements.
- Appearance batches. Light and dark from a single pass.
- DocC documentation.
Contributions are welcome. The example module is a good place to see how the pieces fit together.
ShotKit is an open-source project from Birang Dev. It was built for StackGauge and generates its App Store screenshots.
MIT — see LICENSE.






