widget_snap
Export any Flutter widget to PNG. The widget is rendered offscreen — it never has to be mounted or visible, so content taller or wider than the screen works too.
Zero dependencies. It drives Flutter's own render pipeline (BuildOwner +
PipelineOwner + RenderView) — no screenshot or other third-party capture
package, no platform code. Only flutter itself, which is why it runs on
Android, iOS, web, macOS, Windows, and Linux.
Features
- Capture any widget — mounted or not, larger than the screen or not.
- Size-driven layout: pin the width or the height (or both); the other axis grows to fit the content.
- Inherits your app's theme,
MediaQuery, and text direction viacontext. - Fails loud on build errors instead of silently exporting a blank image.
- Two-layer API: pure bytes (core) or a temp-file path (convenience).
- No permissions required. Bytes stay in memory;
toPngFilewrites only to the app-private temp dir. Saving to the gallery or sharing is your app's step (and its permission prompt), not this package's.
Larger than the screen? One call.
This report never fits a phone screen — it was captured in a single
toPngBytes call. In fact, every image in this README was exported by
widget_snap itself: see tool/readme_images.dart.
Platform support
| Android | iOS | Web | macOS | Windows | Linux |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅* | ✅ | ✅ | ✅ |
* On the web, use toPngBytes — toPngFile throws UnsupportedError
because browsers have no writable filesystem. See Web.
Install
flutter pub add widget_snap
Usage
The API is a pair of extension methods on Widget:
import 'package:widget_snap/widget_snap.dart';
// Core: capture to bytes — you own the IO.
final bytes = await myWidget.toPngBytes(
context, // carries inherited theme/media/direction into the offscreen tree
width: 1080, // optional; default = current view width. Height grows to fit.
// height: 720, // or pin the height instead, and let the width grow (wide content)
);
// upload, preview in-memory (Image.memory), custom storage ...
// Or the convenience wrapper when the next step needs a path:
final path = await myWidget.toPngFile(
context,
filename: 'export.png', // written under the system temp dir
);
await SharePlus.instance.share(ShareParams(files: [XFile(path)]));
// or Gal.putImage(path), ...
Prefer a named entry point? The WidgetSnap facade mirrors both — type
WidgetSnap. and autocomplete shows the whole API:
final bytes = await WidgetSnap.pngBytes(myWidget, context);
final path = await WidgetSnap.pngFile(myWidget, context, filename: 'export.png');
Parameters
| Param | Required | Description |
|---|---|---|
context |
✓ | Source of inherited theme, MediaQuery, and text direction. |
width |
— | Target width in logical pixels. Default = current view width (when height is also unset); the unpinned axis grows to fit the content. |
height |
— | Target height in logical pixels. Pin this for naturally-wide content (timelines, charts) and let the width grow. |
filename |
file variant only | Bare file name, no path separators (written under the system temp dir). |
pixelRatio |
— | Raster scale, default 2.5. Clamped (≥ 1.0) so no output axis exceeds ~4096px. |
delay |
— | Wait before capture so async images (network/asset) resolve; default zero. Prefer precacheImage (see Notes). |
backgroundColor |
— | Fill behind the content, default opaque white. Pass Colors.transparent for a PNG with an alpha channel, or any color to tint the canvas. |
toPngBytes returns the PNG Uint8List; toPngFile returns the written
file's path.
Web
toPngBytes works as-is. There is no filesystem to write to, so instead of
toPngFile, hand the bytes to whatever download/share mechanism your app
uses — e.g. share_plus:
final bytes = await myWidget.toPngBytes(context);
await SharePlus.instance.share(ShareParams(
files: [XFile.fromData(bytes, mimeType: 'image/png', name: 'export.png')],
));
Design boundary
This package knows nothing about your app — no models, no i18n, no state management, no share/download logic. It captures a widget to PNG and hands the result back. Your app owns what happens next (share sheet, save to gallery, upload, …).
Notes & limits
- No
MaterialAppneeded. The offscreen tree wraps your widget in a whiteMaterial+Directionality+MediaQuery, soInk,InkWell, andTextrender as in-app. pixelRatiois clamped to the GPU texture cap. The raster scale is reduced (never below 1.0) so no output axis exceeds ~4096px — the texture cap on low-end devices. The pinned axis is clamped up front, the growing axis after layout once the content's size is known. Content larger than 4096 logical pixels still rides over the cap; if you hit clipping or OOM there, capture a smaller width/height.- A fresh tree is captured, not your live one. The widget is rebuilt offscreen, so runtime state of a live counterpart — a checked checkbox, typed text, a scroll offset — does not carry over. Build the export copy from your app's data.
- No
Overlayin the offscreen tree. Widgets that require anOverlay/Navigatorancestor (Tooltip, dropdowns, anything that pops routes) throw during the offscreen build. The export fails loudly with that error instead of producing a blank image. Strip such widgets from the export copy of your content. - Layout errors fail loud too. Content that fails layout — e.g. a
ListViewgrowing along the unpinned axis ("unbounded height") — rethrows the original framework error instead of exporting garbage. Give scrollables a bounded main axis: pin that axis, or setshrinkWrap: true. - Don't reuse live
GlobalKeys. The offscreen tree mounts on the framework'sBuildOwner(so Flutter-internalGlobalKeys —Ink, form fields — resolve correctly) and is unmounted right after capture. One consequence: don't pass content holding aGlobalKeythat is simultaneously mounted in the live app tree (duplicate-key error in debug) — build a fresh copy of the widget for export instead. - Very large captures throw. When rasterizing or encoding the capture
fails,
toPngBytesthrows aStateErrornaming the pixel size and the fix (capture smaller, or lowerpixelRatio) — never a silent blank image. Measured ceiling on the web (CanvasKit): total area of roughly 180 million pixels (13312×13312 works, 14336×14336 does not); a skinny 400×131072 strip is fine. Native platforms handle far more. Probe your own setup withtest/stress_probe.dart. - Async images:
precacheImagefirst, or passdelay.NetworkImage/ asset decodes paint blank on the first frame. The deterministic fix isawait precacheImage(provider, context)for each image before capturing — the offscreen tree then reads them straight from the image cache.delayremains as a time-based fallback. - Files are temporary.
toPngFilewrites under the system temp dir, which the OS may clean at any time (iOS routinely does). Move the file if you need it to persist.filenamemust be a non-empty bare name — an empty name or path separators throw anArgumentError. - Flutter-version sensitive. Uses the internal render-pipeline API
(
ViewConfiguration.logicalConstraints,RenderView(view:)). Verified on Flutter 3.35 and 3.41 (tests run on the VM and in Chrome). If a Flutter upgrade breaks capture, this is the first place to check.
Roadmap
- JPEG output (quality knob) for photo-heavy content.
- Tiled capture for documents that exceed the GPU texture cap along the growing axis, or the web renderer's ~180M-pixel memory ceiling.
- PDF export (pagination, headers/footers, bookmarks) as a separate layer.
Libraries
- widget_snap
- Widget → PNG export, as extension methods on
Widget— or via theWidgetSnapfacade if you prefer a named entry point: