preview-widget
Preview a Flutter widget in isolation
Render a single widget in Flutter's Widget Previewer so you and the human can iterate on it — design tweaks, light/dark variants, tablet sizing — without booting the full app. The agent screenshots the live preview headlessly; the human optionally opens the same localhost URL in their own Chrome to follow along.
When to use this
All three must hold — if any is false, this skill is the wrong tool:
- The project is a Flutter project (
pubspec.yamlat the cwd,lib/exists). - Flutter ≥ 3.35 is on PATH (or via
fvm flutter). The previewer is experimental on stable but stable enough to design against; older Flutters don't have it. - The user wants to iterate on a single widget, not a full screen or end-to-end flow. Full-screen visual work is what
design-polish+android-emulatoris for.
Setup (caller's machine)
| Requirement | How to satisfy |
|---|---|
| Flutter ≥ 3.35 | Run bash scripts/check_preview_support.sh — fails loudly with an upgrade hint if too old. Override the Flutter binary with FLUTTER_BIN=… (default: flutter, falls back to fvm flutter). |
| Google Chrome / Chromium | Used headlessly by screenshot_preview.sh to capture previews. macOS picks up /Applications/Google Chrome.app automatically; on Linux any of google-chrome, chromium, chromium-browser on PATH works. Override with CHROME_BIN=/path/to/chrome. |
bash 3.2+ (macOS default), awk, grep, find |
Standard. No Python or Node needed. |
There's no per-machine auth, account, or token — everything runs on localhost.
Bundled scripts
scripts/check_preview_support.sh— verifies Flutter ≥ 3.35 and prints the resolved binary + version. Exit 0 = ready, exit 1 = version too old, exit 127 = no Flutter found.scripts/start_preview.sh— launchesflutter widget-preview startin the background, parses the URL out of its log, and writes a small JSON state file. Idempotent: re-runs return the existing URL.--stoptears the server down. Default state dir:./.preview-widget/(override withPREVIEW_WIDGET_STATE_DIR).scripts/screenshot_preview.sh— headless-Chrome screenshot of the running previewer. Flags:--out <path>,--size WxH(default1600x2400),--wait <ms>(virtual-time budget, default10000),--url <url>(override the state file). Prints the absolute output path on stdout.scripts/list_previews.sh— grepslib/for@Preview(...),@<Custom>Preview(...), andextends (Multi)?Previewdefinitions so you don't re-add what's already there.
All four ship with --help. Read it before improvising flags.
One-time setup per Flutter project
Before annotating any widget, the project needs a preview wrapper — a top-level Widget previewWrapper(Widget child) function that recreates the global ancestor chain a real widget assumes (theme cubit / ProviderScope / MaterialApp / localizations / MediaQuery overrides / etc.). The previewer's theme: parameter alone is not enough for any non-trivial app: widgets that read BlocBuilder<ThemeCubit, …> or Theme.of(context) directly will throw or render wrong without the cubit in their tree.
Steps:
- Read the project's
lib/main.dartandlib/app.dart(or equivalent root). Identify everything wrapping theMaterialApp—MultiBlocProvider,ProviderScope,MultiRepositoryProvider, customInheritedWidgets,MediaQueryoverrides, locale/router config. - Copy
assets/preview_wrapper.dart.templatetolib/preview_wrapper.dart. - Fill in the imports and provider list to mirror the real root tree, stubbing any provider that needs runtime state (e.g.
UxCubitgets a defaultDeviceType.mobile; repository cubits get fake/empty states). The wrapper is a const-time tearoff, so all callbacks must be top-level public functions. - See
references/wrapper_patterns.mdfor recipes (BLoC, Riverpod, GoRouter, Localizations,MediaQuery, fonts).
If you're unsure which providers a widget actually needs, search the widget body for Theme.of, BlocBuilder<, context.watch<, context.read<, ProviderScope.containerOf, MediaQuery.of, Localizations.of, GoRouter.of — every one of those is a load-bearing ancestor.
Per-widget workflow
1. Pick the annotation target
@Preview and @MultiPreview apply to:
- top-level functions returning
WidgetorWidgetBuilder - public static methods on a class
- public widget constructors / factories with no required arguments
All values passed to the annotation must be const, and any callback (wrapper:, theme:, localizations:) must be a public top-level (or static) function — closures over private state are rejected by the previewer.
2. Annotate
Single preview, against the project's wrapper:
import 'package:flutter/material.dart';
import 'package:flutter/widget_previews.dart';
import '../preview_wrapper.dart';
(name: 'MyButton — default', wrapper: previewWrapper)
Widget myButtonPreview() => const MyButton();
Multi-preview matrix (light + dark + tablet) via the bundled assets/multi_preview.dart.template → lib/preview_variants.dart:
()
Widget myButtonPreview() => const MyButton();
See references/preview_annotations.md for the full @Preview parameter list and target-restriction rules.
3. Launch the preview server
bash scripts/start_preview.sh
# → Preview: http://localhost:51530
Idempotent — re-running while a server is up just re-prints the URL. The state file at .preview-widget/server.json carries { "url", "pid", "log" }. Add .preview-widget/ to .gitignore.
4. Screenshot the running preview
bash scripts/screenshot_preview.sh
# → /tmp/preview-widget/preview-001.png
Then Read the PNG to see the live preview. Default capture is 1600×2400 viewport (fits ~3 stacked preview tiles); pass --size 1920x4000 for a taller capture or --out /tmp/foo.png to control the path. If the first paint looks blank, bump --wait 20000 (20s) — that's the canvas/Skia bootstrap budget.
5. Iterate
edit Dart → sleep 2 → screenshot → read → reason → repeat
The previewer hot-restarts automatically on file save, so no manual restart is needed. The 2-second sleep gives hot restart time to settle before the next screenshot.
6. Always print the URL at the end of your turn
Every assistant message that touched the previewer must end with the line:
Preview: http://localhost:<port>
This lets the human drop in at any time, or come back later — the server keeps running until start_preview.sh --stop.
7. Clean up when done
bash scripts/start_preview.sh --stop
Kills the server and removes the state file. Optional — leaving it running between sessions is fine.
Discovering existing previews
Before adding a new @Preview, see what's already in the project:
bash scripts/list_previews.sh
# lib/widgets/foo.dart:42 @Preview(name: 'Foo - light', wrapper: previewWrapper)
# lib/widgets/bar.dart:17 @StandardPreview()
# lib/preview_variants.dart:9 final class StandardPreview extends MultiPreview {
Pass an explicit root to scope: bash scripts/list_previews.sh lib/src/widgets.
Gotchas
- The wrapper is the load-bearing piece. A naive
@Previewwithoutwrapper: previewWrapperwill crash or render wrong in any app that reads providers/cubits inside widget bodies. If a widget doesBlocBuilder<ThemeCubit>and the wrapper doesn't put aThemeCubitabove it, the preview throwsProviderNotFoundException— not a previewer bug. size:constrains the widget, not the canvas. An unconstrained widget withoutsize:is auto-sized to roughly half the previewer canvas, which is rarely what you want for a single component. Passsize: Size(360, 80)for a button-sized component orsize: Size(1024, 1366)for a tablet layout.- All annotation values must be const. Including the
wrapper:,theme:, andlocalizations:callbacks — they have to be public top-level (or static) function tearoffs, not lambdas. Anything captured from outside fails compile. - Asset paths must be
packages/<name>/...in previewed widgets. A widget that doesImage.asset('assets/foo.png')works in the real app but breaks in the previewer; switch toImage.asset('packages/<your_pkg>/assets/foo.png')for both contexts. - No
dart:io,dart:ffi, or native plugins. The previewer runs in Chrome — anything that touchesFile,Platform, native channels, or platform plugins throws when the preview tries to render. Conditional imports (if (dart.library.html)etc.) sidestep this. - First paint is slow. Flutter web bootstraps to JS/Skia on the first preview load — 5–10s is typical, longer on slow disks. The
--wait 10000default is usually enough; bump it (e.g.--wait 20000) before assuming the screenshot is broken. - Whole-page screenshots only. The previewer doesn't expose per-preview URLs, so
screenshot_preview.shcaptures the entire page (all preview tiles stacked). The agent reasons about which tile is which by thename:label rendered above it. If you only want one preview visible, temporarily comment out the others — or use a tall--sizeand rely on thegroup:parameter to keep related variants adjacent. - Hot-restart is automatic but not instant. After editing a
.dartfile, give the previewer ~1–2s before screenshotting. Edits to the wrapper itself take longer; if a screenshot still shows the old state aftersleep 5, restart the server (--stopthen re-run). - One project per server. As of Flutter 3.35 the previewer doesn't support multi-project workspaces in the IDE flow; the CLI server only knows about the current project. If the project uses Pub workspaces, run the server from the workspace root.
flutter widget-previewis experimental. APIs and CLI flags may shift between Flutter versions. If a flag this skill uses stops working, checkflutter widget-preview --helpagainst whatstart_preview.shpasses.
Quick reference
# 0. (one-time) Verify Flutter is recent enough.
bash skills/preview-widget/scripts/check_preview_support.sh
# 1. (one-time per project) Copy the wrapper template, fill in providers.
cp skills/preview-widget/assets/preview_wrapper.dart.template lib/preview_wrapper.dart
# …edit lib/preview_wrapper.dart, then add `.preview-widget/` to .gitignore
# 2. Annotate the widget (in its own file or alongside it).
# @Preview(name: 'MyWidget', wrapper: previewWrapper)
# Widget myWidgetPreview() => const MyWidget();
# 3. Start server, capture URL.
bash skills/preview-widget/scripts/start_preview.sh
# → http://localhost:51530
# 4. Iterate: edit → sleep 2 → screenshot → read → repeat.
bash skills/preview-widget/scripts/screenshot_preview.sh
# 5. Stop when done.
bash skills/preview-widget/scripts/start_preview.sh --stop
End every assistant turn that touched the previewer with Preview: http://localhost:<port> so the human can follow along.