Coming from Flutter or Jetpack Compose

Gophics reimplements the rendering architecture behind both — immutable widgets, element reconciliation, constraint layout, GPU layer compositing — in idiomatic Go. So most of what you know transfers. This page is about the parts that do not, because those are what cost you a day each.

Read it in three passes. What maps directly is reassurance: skim it and move on. What looks the same and is not is the part worth actually reading — superficial familiarity is more expensive than unfamiliarity, because you will not go looking. What has no equivalent is the part to read before you commit to a port.

If you are coming from Compose specifically: the closer relative is Flutter, so read the Flutter column even where the Compose one is blank. Compose is a compiler-assisted reactive system with a snapshot state model and fine-grained recomposition; Gophics is explicit, value-based and Flutter-shaped. The Modifier chain has no counterpart at all — layout here is nesting, as in Flutter.

What maps directly

Same idea, different spelling. If your instinct is from the left two columns, it is right.

FlutterComposeGophics
StatelessWidget@Composable funA struct with Build(ctx widget.Ctx) widget.Widget
StatefulWidget + Stateremember { mutableStateOf() }CreateState() widget.State + a struct embedding widget.StateBase[W]
setState(() {})assignment to a MutableStates.SetState(func() { … })
BuildContextLocalContext, composition scopewidget.Ctx, passed to Build
InheritedWidget, ProviderCompositionLocalProviderwidget.Provide[T]{Value, Child} + ctx.Of[T]() / ctx.MustOf[T]()
Column, Row, Expanded, FlexibleColumn, Row, Modifier.weightwidget.Column, Row, Expand, Flexible
Padding, Center, Align, SizedBoxModifier.padding, Box alignment, Spacerwidget.Padding, Center, Align, Sized
StackBoxwidget.Stack
BoxConstraintsConstraintslayout.Constraints — the protocol is Flutter's, kept exactly
LayoutBuilderBoxWithConstraintswidget.LayoutBuilder
ListView.builderLazyColumnwidget.LazyList
GridView, WrapLazyVerticalGrid, FlowRowwidget.Grid, widget.Wrap
SingleChildScrollView, ScrollControllerverticalScroll, ScrollStatewidget.Scroll, widget.ScrollController
GestureDetector, MouseRegionModifier.clickable / pointerInputwidget.Interactive{Gestures: …}
Focus, autofocusFocusRequesterGestures.OnFocus, Interactive.Autofocus
Key, ValueKeykey(…) { }widget.WithKey{Key, Child}
CustomPaint + CanvasCanvas(modifier) + DrawScopewidget.Canvas + paint.Canvas
AnimatedContainer, TweenAnimationBuilderanimateFloatAsState, animateColorAsStatewidget.Animated[T], AnimateColor, AnimateFloat, AnimatedScale
AnimationController, CurvesAnimatable, Easinganim.Controller, anim.EaseInOut / EaseOut / Spring
Opacity, Transform, ClipRRectModifier.alpha / graphicsLayer / clipwidget.Opacity, widget.Transform, Canvas{Clip: true}
Theme.of(context)MaterialTheme.colorSchemetheme.Of(ctx)
Navigator.push, routesNavHost, navControllerwidget.Navigator{Home}, then ctx.MustOf[widget.Nav]().Push(page)
Heroshared element transitionswidget.Hero
Dismissible, ReorderableListViewSwipeToDismissBox, reorderable modifierswidget.Dismissible, widget.Reorderable
Draggable / DragTargetdragAndDropSource / Targetwidget.Draggable / widget.DropTarget
Semantics, ExcludeSemanticsModifier.semantics, clearAndSetSemanticswidget.Semantics, Interactive.Sem, Semantics{Hidden: true}
SelectableText, SelectionAreaSelectionContainerwidget.SelectableText, widget.SelectionArea
TextFieldTextFieldwidget.TextField (unstyled), theme.Field (styled)
SafeArea, MediaQuery.paddingWindowInsetswidget.SafeArea, ctx.SafeInsets()
debugPaintSizeEnabledLayout Inspector boundsapp.Config{Debug: true}
widget testsCompose UI testsapptest — and this one is stronger: see below

What looks the same and is not

This is the section that matters. Each of these is a place where your existing instinct produces code that compiles, runs, and is subtly wrong.

1. SetState is not optional, and nothing warns you

There is no snapshot system and no observable state. A field mutated outside SetState simply does not repaint: no error, no warning, no lint. If you come from Compose, where assigning to a MutableState is the whole mechanism, this is the single most likely first bug. The rule has one more part worth memorising: SetState from the UI goroutine, PostState from anywhere else.

Signals and fine-grained reactivity were evaluated for this project and deliberately rejected — in Go, without a compiler plugin, they cost more in ceremony than the explicit call does.

2. Rebuilding is subtree-scoped, not read-tracked

Flutter developers already know this. Compose developers should recalibrate: there is no recomposition scope, no skippability analysis, no derivedStateOf. SetState marks that element dirty, its Build runs again, and the reconciler diffs the returned description against the element tree — stopping where the description is unchanged. Performance work here means hoisting state downward so the dirty subtree is small, which is the Flutter habit, not the Compose one.

3. There is no didUpdateWidget and no remember(key)

The State lifecycle is Init(ctx) once at mount and Dispose() once at unmount. That is all. If you need to react to a changed configuration, compare against what you last saw inside Build — W() gives you the current widget value, typed, with no cast. There is no hook that fires on reconfiguration, and no keyed memoisation primitive.

4. Capabilities return nil, and nil is an answer

A Flutter plugin that is not available throws MissingPluginException; a Compose app calls an Android API that exists because it is on Android. Here, ctx.Battery() returns nil when the running platform cannot tell you — and the framework refuses to publish a capability whose implementation always returns nothing, precisely so that nil keeps meaning "unsupported" rather than "no data". The consequence for your code is that the correct response to nil is usually to remove the affordance, not to show a disabled one or to catch an error.

There is a timing wrinkle with no analogue in either toolkit: capabilities are nil during Init and the first Build, because the tree mounts before the shell is wired. They arrive on the first frame and the runner marks the tree dirty. So read them in Build and guard a subscription to run once — never in Init. See the capability recipe.

5. Background work is a goroutine, not a Future or a coroutine

No FutureBuilder, no StreamBuilder, no LaunchedEffect, no rememberCoroutineScope. Start a goroutine and deliver the result with s.PostState(func(){ … }), which applies on the UI goroutine at the top of the next frame and is dropped if the widget has left the tree. Streaming live data into a view — awkward in most UI stacks — is the easy case here.

Prefer SetState wherever it applies: PostState defers to the next frame, so a handler that reads the state back after writing it would see the old value.

6. Numbers are float32 logical pixels — there is no Dp

No .dp suffix, no density parameter to thread, no double. Every coordinate in geom, in the widget tree and in a gesture callback is a float32 in logical pixels, and the conversion to physical pixels happens once at the presenter. The upside is that nothing has to be converted at a boundary; the trap is the mirror image of Compose's — if you ever find yourself multiplying by a scale factor in app code, you are about to introduce a bug that is invisible at 1× and wrong at 2×. See the API tour for the full version of that warning.

7. The widget catalog is small on purpose, and there is no Material library

widget is unstyled primitives; theme is a small styled layer — buttons, cards, fields, list tiles, tabs, pickers, a table. It is not Material 3, there is no Cupertino set, and there are no slot-based component APIs. A "Button" or "Card" you need and cannot find is built from Fill + Padding + Interactive + Text, which is five lines. The corollary: do not invent widget names from memory. go doc ./widget is the whole catalog on one screen, and a plausible-sounding widget frequently does not exist.

8. Text needs a font, explicitly

Flutter bundles Roboto; Compose has the platform's fonts. Here app.Config.Font is required []byte of a TTF or OTF, and without it nothing draws — which looks like a rendering failure and is not. Named faces go in FontFamilies, per-rune fallbacks in Fallbacks, and SystemFonts: true reaches the OS fonts on desktop only. The Go fonts cover Latin, Greek and Cyrillic, so ✓ ✕ ⋮ ↩, CJK and emoji come out as boxes until you add a face that has them. On the web and mobile, bundled faces are the whole answer.

9. Theming is struct copying, not a composable scope

A theme is a plain value: derive from theme.Light() or theme.Dark(), change the tokens that are yours, provide it. The one non-obvious part is that Border (decorative chrome) and Outline (the edge of an interactive control) are separate tokens, and conflating them erases your own form controls — the built-in glass presets did exactly that before they were split.

10. Hot reload is restart, everywhere

There is no hot reload on any platform, and the reason is Go rather than Apple: nothing in the language or runtime can replace code in a running process, and -buildmode=plugin — the one mechanism that comes close — needs cgo and does not exist for android/* or ios/* at any setting. (The usual "iOS forbids dynamic code loading" argument is too strong: Flutter's iOS debug builds run the Dart JIT, which Apple permits. It is not the obstacle here.)

What gophics dev gives you instead is state-preserving hot restart, keyed by position in the widget tree, on every target: desktop, web, the Android emulator and the iOS simulator. Save, and the app comes back on the same page with the same scroll offset and the same field contents. What a widget does not expose resets (see widget.Snapshottable), and a structural edit above a widget drops its state.

The honest difference is the clock, measured on an Apple Silicon Mac: 1.4 s desktop, ~4 s web, ~11 s on an Android emulator and ~20 s on a pre-booted iOS simulator, against roughly a second for a Flutter reload anywhere. gomobile bind is 60–70% of the mobile figure and is not incrementally rebuildable. Against that, test binaries cross-compile and run on a real device over adb or SSH with no application wrapper, no JNI and no emulator — a different thing, not a replacement, but a faster feedback loop for logic than either incumbent offers.

11. Testing is better than you are used to, and worth re-learning

Do not port your Flutter widget-test habits unchanged. apptest mounts the real tree, drives taps, keys, drags, scrolls and IME composition, and asserts on actual rendered pixels against a committed golden image — headlessly, deterministically, with no device and no emulator. It also exposes the semantics tree as an ordinary value, so accessibility is unit-testable. Tap by label rather than by coordinate (a.TapLabel("Increment")), and use apptest.Tol(apptest.AntiAliased) rather than Exact for any golden committed from one machine and checked on another.

What has no equivalent

Read this before porting, not during. Several of these are load-bearing for a large class of commercial apps, and the honest answer to some of them is "choose a different toolkit for this app".

Flutter / ComposeStatus here
AndroidView, UiKitView, PlatformViewAbsent on native targets. You cannot embed a native subview — a map, a video player, an advertisement, an in-app browser — in the widget tree. The stated reasoning covers authentication (which belongs in the platform's own browser session) and does not cover the rest. If an embedded map is your app's core, this is a blocker and not a workaround.
pub.dev / Maven CentralNo package ecosystem, and no extension point for one: the capability generator parses this repository's own source as its single source of truth, so a third party cannot ship a package that adds a capability without patching the tree. Go's own ecosystem — HTTP, databases, parsing, everything non-UI — is of course unaffected, which is most of an app.
RenderObject subclassing, custom LayoutNot available from outside the module. layout.Box is a public protocol, but the bridge from a widget to a box is unexported and the concrete boxes live in internal/layoutbox, so you cannot write a custom render widget. Compose from the primitives, or drop to widget.Canvas and draw. LayoutBuilder and LayoutObserver cover most of what people actually wanted a custom layout for.
Slivers — CustomScrollView, SliverAppBarNo sliver protocol. A collapsing app bar driven by scroll offset has to be assembled by hand from Scroll.OnOffset and an animated header.
FragmentProgram, BlendMode, ShaderMask, radial and sweep gradientsAbsent. Two-stop linear gradients by axis only; no blend modes exposed, no colour or image filters beyond a backdrop blur, round caps and joins only, no dash patterns, no arbitrary-path clipping, no custom shaders. This is the biggest single gap in expressive power against both incumbents.
Colour emoji in textDraws nothing. Bitmap and SVG glyph formats are not emitted, so a colour emoji is invisible — not tofu, nothing. Any app with user-entered text will show it. Monochrome symbol fonts work fine.
Velocity-aware springs, flutter_physics-style handoffAbsent. A gesture released mid-flight cannot pass its velocity into the animation that continues it. The real solver exists inside the scroll implementation and is private to it.
ARB / stringResource, message extraction, full ICUPartial. 29 locales with hand-written plural rules in intl; no ordinal plurals, no relative-time formatting, no locale-aware currency patterns, no extraction tooling.
DevTools, Layout Inspector, a widget inspector GUINo GUI. What exists instead: Config.Debug box bounds, app.FrameSummary percentiles, Config.GraphicsLog for every rendering fallback taken, and tools/uitrace for scroll measurement. All useful, none of it a timeline view.
Desktop packaging — .app, MSIX, AppImage, notarisationNot built. The CLI produces a binary and stops. Mobile scaffolding, by contrast, generates Xcode and Gradle projects with Kotlin and Swift hosts.
A stability guaranteeNone. v0.1.0. The generated API manifest records names and kinds but not signatures, so a signature-only break can pass a green build. Pin your version and read the diff.

What you gain, which is the reason to do this at all

Stated plainly, because a migration page that only lists losses is not useful.

GainAgainst Flutter and Compose
go build is the entire toolchainNo SDK, no engine artifact, no toolchain doctor, no Gradle. A dependency line in go.mod and a static binary out the other end. GOOS=windows go build from a Mac.
Headless, pixel-exact testingGolden images, fuzzing, benchmarks and profiling through ordinary go test, with no emulator and no bespoke harness. Flutter's widget tests are weaker and its integration tests need a device.
Test binaries on real devices with no wrappergo test -c for GOOS=android, push over adb, run. No app, no JNI, no gomobile. Neither incumbent can do this, and a second backend's opinion has repeatedly disagreed with the development machine's.
Desktop accessibility on all three platformsmacOS, Windows and Linux are all implemented. This is a known weak point for Flutter and a procurement requirement in enterprise and government.
Honest capabilitiesnil distinguishes "unsupported" from "no data", so apps degrade correctly instead of showing dead affordances. A stronger contract than the plugin model offers.
Goroutines with one UI threadStreaming live data into a view is the easy case rather than the awkward one.
A terminal targetThe same widget tree, rasterized to a terminal over the kitty graphics protocol — including over SSH. Neither competitor has one.
Nine direct dependenciesA supply chain a security team can review in an afternoon, with no engine artifact and no CGo anywhere.

A realistic porting order

If you are moving an existing app rather than starting fresh:

The learning path →  ·  The cookbook  ·  The API tour