Get started

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).

Run an example

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

Your first widget

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.

Ship it everywhere with the CLI

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

Test the whole UI with go test

This 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.

Next