You need Go (1.26+). That's the whole toolchain — no SDK, no Node, no C compiler. For the web target you also need a browser with WebGPU (recent Chrome, Edge, or Safari).
git clone https://github.com/doug/gophics && cd gophics go run ./examples/hello # a colored, vsynced, resizable window go run ./examples/todo # a real widget app: state, taps, hover, text input go run ./examples/hn # a HackerNews client: feed → comments, scroll, links
Widgets are plain struct values; state is a generic base you embed. Here's a complete counter — save it as main.go and go run .:
// Command counter is the smallest complete gophics app, and the one the
// homepage and the getting-started guide both show.
//
// It is a real example rather than a snippet in the docs so that it compiles
// in CI and its screenshot is generated from this exact source — a sample that
// only exists as HTML drifts from the API, and this one already had.
//
// The whole program:
//
// go run ./examples/counter # a window
// GOOS=js GOARCH=wasm go build # the same UI in a browser
package main
import (
"fmt"
"log"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// Counter is a widget: a plain struct value describing what to show.
type Counter struct{ Start int }
func (Counter) CreateState() widget.State { return &counterState{} }
// counterState holds what changes. StateBase[Counter] gives it SetState and a
// typed W() — the current Counter value, with no cast.
type counterState struct {
widget.StateBase[Counter]
n int
}
func (s *counterState) Init(widget.Ctx) { s.n = s.W().Start }
func (s *counterState) Build(ctx widget.Ctx) widget.Widget {
// Pinned to the light theme rather than following the system setting.
//
// Most apps should follow it — theme.Of does that by default, and the
// other examples rely on it. This one is the figure on the home page,
// shown beside a still of itself, and a screenshot cannot follow anybody's
// colour scheme. Pinning both to light keeps the pair honest; the
// alternative is shipping two stills and picking between them, which is a
// lot of machinery for a counter.
th := theme.Light()
return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
Child: widget.Center(widget.Column(
widget.Text{Value: "TAPS", Size: th.Type.Caption, Color: th.Muted},
widget.Sized{H: 4},
widget.Text{
Value: fmt.Sprintf("%d", s.n),
Size: th.Type.Display,
Font: theme.FontBold,
Color: th.Text,
},
widget.Sized{H: 18},
theme.Button{
Label: "Increment",
Primary: true,
OnTap: func() { s.SetState(func() { s.n++ }) },
},
))}}
}
func main() {
err := app.Run(Counter{Start: 3}, app.Config{
Title: "counter",
Size: geom.Size{W: 320, H: 220},
// Light in both schemes, matching the pinned theme above.
Background: theme.Light().Bg,
Font: goregular.TTF,
FontFamilies: map[string][]byte{
theme.FontBold: gobold.TTF,
},
})
if err != nil {
log.Fatal(err)
}
}
This is examples/counter verbatim — the same program on the home page, generated from the source so it cannot drift from the API.
The gophics CLI drives the multi-platform loops so you don't have to remember the incantations:
go install github.com/doug/gophics/cmd/gophics@latest gophics run -p desktop . # native window gophics dev -p web . # browser, live-reload gophics run -p android ./examples/hn/mobile # device/emulator (needs the SDK) gophics create my-app # scaffold a new cross-platform app gophics doctor # check the toolchain per platform
go testThis is the part no other GUI framework gives you. Mount a widget tree headless, drive it, and assert on the rendered pixels — no window, no emulator, no device farm:
package main
import (
"testing"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/apptest"
"github.com/doug/gophics/geom"
)
// TestCounter drives the whole app with no window, no GPU and no display —
// the same renderer that draws it on screen, running under `go test`.
func TestCounter(t *testing.T) {
a := apptest.New(t, Counter{Start: 3}, apptest.WithConfig(app.Config{
Size: geom.Size{W: 320, H: 220}, Font: goregular.TTF,
}), apptest.Tol(apptest.AntiAliased))
// Tap the button the way a screen reader would find it, rather than
// hardcoding a pixel that moves the moment the layout changes.
a.TapLabel("Increment")
if !a.HasLabel("4") {
t.Errorf("tapping Increment did not advance the count. Labels: %v", a.Labels())
}
// The frame itself is checked against a committed golden, so a change in
// how it looks fails here rather than being noticed later by a person.
//
// AntiAliased rather than Exact, because the golden is committed from one
// machine and checked on another: CI runs Linux and this file's reference
// image was rendered on macOS. Exact failed there on 4 of 70,400 pixels
// differing by 1/255 — sub-LSB float rounding in the rasteriser, not a
// change anyone made. Tolerance's own documentation draws this line:
// "use it when comparing across machines; prefer Exact within one."
//
// The bound is still tight enough to do its job. 2/255 on at most 0.5% of
// pixels catches a colour shift or a moved element; it does not catch a
// last-bit difference in how two libms round the same blend.
a.Golden("counter")
}
The same offscreen path means a server can render a widget tree to a PNG or a report with no display at all.